|
|
@ -1,32 +1,56 @@ |
|
|
|
<template> |
|
|
|
<BasicDrawer |
|
|
|
<Drawer |
|
|
|
v-bind="$attrs" |
|
|
|
@register="registerDrawer" |
|
|
|
showFooter |
|
|
|
title="文档审核结果" |
|
|
|
width="80%" |
|
|
|
:canFullscreen="true" |
|
|
|
:visible="visible" |
|
|
|
@close="handleClose" |
|
|
|
:title="title" |
|
|
|
:width="width" |
|
|
|
:closable="true" |
|
|
|
:maskClosable="true" |
|
|
|
:destroyOnClose="true" |
|
|
|
> |
|
|
|
<template #default> |
|
|
|
<div class="document-review-container"> |
|
|
|
<div class="document-review-container"> |
|
|
|
<div class="split-layout"> |
|
|
|
<!-- 左侧PDF预览 --> |
|
|
|
<div class="pdf-preview"> |
|
|
|
<div class="pdf-container" ref="pdfContainer"> |
|
|
|
<canvas ref="pdfCanvas"></canvas> |
|
|
|
</div> |
|
|
|
<div class="pdf-controls"> |
|
|
|
<Button @click="zoomOut" :disabled="scale <= 0.5">-</Button> |
|
|
|
<Button @click="prevPage" :disabled="currentPage <= 1" class="ml-2"> |
|
|
|
<LeftOutlined /> |
|
|
|
</Button> |
|
|
|
<span class="page-info">{{ currentPage }} / {{ totalPages }}</span> |
|
|
|
<Button @click="nextPage" :disabled="currentPage >= totalPages" class="ml-2"> |
|
|
|
<RightOutlined /> |
|
|
|
</Button> |
|
|
|
<Button @click="zoomIn" :disabled="scale >= 3" class="ml-2">+</Button> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
|
|
|
|
<!-- 右侧审核结果 --> |
|
|
|
<div class="tasks-container"> |
|
|
|
<Collapse v-model:activeKey="activeCollapseKeys" class="tasks-collapse"> |
|
|
|
<CollapsePanel |
|
|
|
<Tabs v-model:activeKey="activeCollapseKeys[0]"> |
|
|
|
<TabPane |
|
|
|
v-for="(category, index) in filteredTaskResultDetail" |
|
|
|
:key="index.toString()" |
|
|
|
:header="category.name + ' (' + category.results.length + ')'" |
|
|
|
:tab="category.name + ' (' + category.results.length + ')'" |
|
|
|
> |
|
|
|
<div class="category-items"> |
|
|
|
<Collapse v-model:activeKey="activeItemKeys" class="items-collapse"> |
|
|
|
<CollapsePanel |
|
|
|
<div class="items-card-list"> |
|
|
|
<Card |
|
|
|
v-for="(item, idx) in category.results" |
|
|
|
:key="getItemKey(index, idx, item)" |
|
|
|
:key="getItemKey(index, idx, item)" |
|
|
|
class="item-card" |
|
|
|
:bordered="true" |
|
|
|
:bodyStyle="{ padding: activeItemKeys.includes(getItemKey(index, idx, item)) ? '16px' : '0', height: activeItemKeys.includes(getItemKey(index, idx, item)) ? 'auto' : '0', overflow: 'hidden', transition: 'all 0.3s' }" |
|
|
|
> |
|
|
|
<template #header> |
|
|
|
<div class="item-header-content"> |
|
|
|
<template #title> |
|
|
|
<div class="item-header-content" @click="toggleItemExpand(index, idx, item)"> |
|
|
|
<div class="item-info"> |
|
|
|
<span class="item-serial">{{ item.serialNumber }}</span> |
|
|
|
<span class="item-title">{{ item.issueName }}</span> |
|
|
|
<span class="item-title">{{ item.existingIssues }}</span> |
|
|
|
</div> |
|
|
|
<div class="item-actions"> |
|
|
|
<Switch |
|
|
@ -35,6 +59,7 @@ |
|
|
|
un-checked-children="未读" |
|
|
|
:loading="loading && currentOpId === item.id && currentOpField === 'isRead'" |
|
|
|
@change="(checked: any) => handleStatusChange(item.id!, 'isRead', checked ? '1' : '0')" |
|
|
|
@click.stop |
|
|
|
/> |
|
|
|
<Switch |
|
|
|
class="ml-3" |
|
|
@ -43,17 +68,34 @@ |
|
|
|
un-checked-children="未采纳" |
|
|
|
:loading="loading && currentOpId === item.id && currentOpField === 'isAdopted'" |
|
|
|
@change="(checked: any) => handleStatusChange(item.id!, 'isAdopted', checked ? '1' : '0')" |
|
|
|
@click.stop |
|
|
|
/> |
|
|
|
<DownOutlined v-if="!activeItemKeys.includes(getItemKey(index, idx, item))" class="expand-icon ml-3" /> |
|
|
|
<UpOutlined v-else class="expand-icon ml-3" /> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</template> |
|
|
|
<div class="item-content"> |
|
|
|
<!-- 基于配置动态渲染内容区域 --> |
|
|
|
<template v-for="(section, sectionIndex) in getContentSections(category.type, item)" :key="sectionIndex"> |
|
|
|
<div class="item-content" v-if="activeItemKeys.includes(getItemKey(index, idx, item))"> |
|
|
|
<template v-for="(section, sectionIndex) in getContentSections(receivedTaskType, item)" :key="sectionIndex"> |
|
|
|
<div v-if="getItemValue(item, section.field)" class="content-section"> |
|
|
|
<div class="section-title">{{ section.title }}:</div> |
|
|
|
<div class="section-content markdown-content" v-html="renderContent(section, getItemValue(item, section.field))"></div> |
|
|
|
<!-- 特殊处理审查要点列表 --> |
|
|
|
<div class="section-title"> |
|
|
|
{{ section.title }}: |
|
|
|
<Button |
|
|
|
type="link" |
|
|
|
size="small" |
|
|
|
class="copy-btn" |
|
|
|
@click.stop="copyContent(getItemValue(item, section.field))" |
|
|
|
v-if="getItemValue(item, section.field)" |
|
|
|
> |
|
|
|
<CopyOutlined /> 复制 |
|
|
|
</Button> |
|
|
|
</div> |
|
|
|
<div |
|
|
|
class="section-content markdown-content" |
|
|
|
v-html="renderContent(section, getItemValue(item, section.field))" |
|
|
|
v-if="getItemValue(item, section.field)" |
|
|
|
@click="locateByText(getItemValue(item, section.field))" |
|
|
|
></div> |
|
|
|
<div v-if="section.type === 'reviewPointsList' && section.field === 'reviewBasis' && item.reviewBasis && item.reviewBasis.reviewPoints && item.reviewBasis.reviewPoints.length"> |
|
|
|
<Divider style="margin: 8px 0" /> |
|
|
|
<ul class="review-points"> |
|
|
@ -65,14 +107,14 @@ |
|
|
|
</div> |
|
|
|
</template> |
|
|
|
</div> |
|
|
|
</CollapsePanel> |
|
|
|
</Collapse> |
|
|
|
</Card> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</CollapsePanel> |
|
|
|
</Collapse> |
|
|
|
</TabPane> |
|
|
|
</Tabs> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</template> |
|
|
|
</div> |
|
|
|
<template #footer> |
|
|
|
<div class="drawer-footer"> |
|
|
|
<div class="status-switches"> |
|
|
@ -93,33 +135,83 @@ |
|
|
|
<Button type="primary" @click="handleClose">关闭</Button> |
|
|
|
</div> |
|
|
|
</template> |
|
|
|
</BasicDrawer> |
|
|
|
<PageSelectModal |
|
|
|
:visible="showPageSelectModal" |
|
|
|
:pages="matchedPages" |
|
|
|
:onSelect="handlePageSelect" |
|
|
|
:onClose="handlePageModalClose" |
|
|
|
/> |
|
|
|
</Drawer> |
|
|
|
</template> |
|
|
|
|
|
|
|
<script lang="ts" setup> |
|
|
|
import { ref, watch, onMounted, computed } from 'vue'; |
|
|
|
import { BasicDrawer, useDrawerInner } from '@/components/Drawer'; |
|
|
|
import { ref, watch, onMounted, computed, onBeforeUnmount, nextTick, h, resolveComponent } from 'vue'; |
|
|
|
import { Drawer, Button, Card, Switch, Divider, Tabs, TabPane, Modal } from 'ant-design-vue'; |
|
|
|
import { DownOutlined, UpOutlined, CopyOutlined, LeftOutlined, RightOutlined, AimOutlined } from '@ant-design/icons-vue'; |
|
|
|
import { DocumentTaskResultDetailVO } from '@/api/documentReview/DocumentTaskResults/model'; |
|
|
|
import { updateResultItemStatus } from '@/api/documentReview/DocumentTaskResults'; |
|
|
|
import { updateResultItemStatus, getPdfStream } from '@/api/documentReview/DocumentTaskResults'; |
|
|
|
import { message } from 'ant-design-vue'; |
|
|
|
import { Switch, Tabs, TabPane, Divider, Button, Collapse, CollapsePanel } from 'ant-design-vue'; |
|
|
|
import MarkdownIt from 'markdown-it'; |
|
|
|
import * as pdfjsLib from 'pdfjs-dist'; |
|
|
|
import { PDFDocumentProxy } from 'pdfjs-dist/types/src/display/api'; |
|
|
|
import PageSelectModal from './PageSelectModal.vue'; |
|
|
|
|
|
|
|
// 设置PDF.js worker路径 |
|
|
|
pdfjsLib.GlobalWorkerOptions.workerSrc = '../../../node_modules/pdfjs-dist/build/pdf.worker.min.mjs'; |
|
|
|
|
|
|
|
const props = defineProps({ |
|
|
|
visible: { |
|
|
|
type: Boolean, |
|
|
|
default: false |
|
|
|
}, |
|
|
|
title: { |
|
|
|
type: String, |
|
|
|
default: '文档审核结果' |
|
|
|
}, |
|
|
|
width: { |
|
|
|
type: [String, Number], |
|
|
|
default: '80%' |
|
|
|
}, |
|
|
|
taskResultDetail: { |
|
|
|
type: Array as PropType<DocumentTaskResultDetailVO[]>, |
|
|
|
default: () => [] |
|
|
|
}, |
|
|
|
taskInfo: { |
|
|
|
type: Object, |
|
|
|
default: () => ({}) |
|
|
|
} |
|
|
|
}); |
|
|
|
|
|
|
|
const emit = defineEmits(['update:visible', 'close']); |
|
|
|
|
|
|
|
const md = new MarkdownIt({ |
|
|
|
html: true, |
|
|
|
linkify: true, |
|
|
|
typographer: true, |
|
|
|
breaks: true, |
|
|
|
}); |
|
|
|
|
|
|
|
const activeCollapseKeys = ref<string[]>([]); |
|
|
|
const activeCollapseKeys = ref<string[]>(['0']); |
|
|
|
const activeItemKeys = ref<string[]>([]); |
|
|
|
const taskResultDetail = ref<DocumentTaskResultDetailVO[]>([]); |
|
|
|
const taskInfo = ref<Recordable>({}); |
|
|
|
const loading = ref<boolean>(false); |
|
|
|
const currentOpId = ref<string>(''); |
|
|
|
const currentOpField = ref<string>(''); |
|
|
|
const expandReadItems = ref<boolean>(false); |
|
|
|
const expandAdoptedItems = ref<boolean>(false); |
|
|
|
const receivedTaskType = ref<string>(''); |
|
|
|
|
|
|
|
const pdfContainer = ref<HTMLElement | null>(null); |
|
|
|
const pdfCanvas = ref<HTMLCanvasElement | null>(null); |
|
|
|
let pdfDoc: PDFDocumentProxy | null = null; |
|
|
|
const currentPage = ref(1); |
|
|
|
const totalPages = ref(0); |
|
|
|
const scale = ref(1); |
|
|
|
|
|
|
|
const showPageSelectModal = ref(false); |
|
|
|
const matchedPages = ref<number[]>([]); |
|
|
|
const pendingText = ref(''); |
|
|
|
|
|
|
|
let currentRenderTask: any = null; // 记录当前渲染任务 |
|
|
|
|
|
|
|
// 内容区域配置,定义不同类型任务显示哪些内容 |
|
|
|
interface ContentSectionConfig { |
|
|
@ -158,11 +250,32 @@ |
|
|
|
nestedFields: ['reviewContent', 'reviewPoints'] |
|
|
|
} |
|
|
|
], |
|
|
|
// 可以添加更多任务类型的配置 |
|
|
|
"checkCompanyName": [ |
|
|
|
{ field: 'modificationDisplay', title: '相关原文', type: 'markdown' }, |
|
|
|
], |
|
|
|
"checkTitleName": [ |
|
|
|
{ field: 'modificationDisplay', title: '相关原文', type: 'markdown' }, |
|
|
|
], |
|
|
|
"checkPlaceName": [ |
|
|
|
{ field: 'modificationDisplay', title: '相关原文', type: 'markdown' }, |
|
|
|
], |
|
|
|
"checkRepeatText": [ |
|
|
|
{ field: 'originalText', title: '第一段原文', type: 'markdown' }, |
|
|
|
{ field: 'comparedText', title: '第二段原文', type: 'markdown' }, |
|
|
|
{ field: 'modificationDisplay', title: '相似情况', type: 'markdown' }, |
|
|
|
], |
|
|
|
"checkDocumentError": [ |
|
|
|
{ field: 'originalText', title: '原文', type: 'markdown' }, |
|
|
|
{ field: 'modifiedContent', title: '修改建议', type: 'markdown' }, |
|
|
|
{ field: 'modificationDisplay', title: '修改情况', type: 'markdown' }, |
|
|
|
], |
|
|
|
// 可以添加更多任务类型的配置,如checkDocumentError、schemEvaluation等 |
|
|
|
}; |
|
|
|
|
|
|
|
// 根据任务类型获取内容配置 |
|
|
|
const getContentSections = (taskType: string, item: any): ContentSectionConfig[] => { |
|
|
|
const getContentSections = (taskType: string | undefined, item: any): ContentSectionConfig[] => { |
|
|
|
// 如果未提供类型或类型不存在,使用默认配置 |
|
|
|
if (!taskType) return defaultContentSections; |
|
|
|
// 如果存在特定类型的配置则使用,否则使用默认配置 |
|
|
|
return taskTypeContentConfig[taskType] || defaultContentSections; |
|
|
|
}; |
|
|
@ -207,23 +320,23 @@ |
|
|
|
return content; |
|
|
|
} |
|
|
|
|
|
|
|
return value.toString(); |
|
|
|
return value.toString().replace(/\n/g, '<br>'); |
|
|
|
}; |
|
|
|
|
|
|
|
// 根据条件过滤展示的结果 |
|
|
|
const filteredTaskResultDetail = computed(() => { |
|
|
|
if (taskResultDetail.value.length <= 0) { |
|
|
|
if (props.taskResultDetail.length <= 0) { |
|
|
|
return []; |
|
|
|
} |
|
|
|
|
|
|
|
// 如果只有两个或更少的类别,或者第一个类别的results数量<=5个,只展示第一个类别 |
|
|
|
if (taskResultDetail.value.length <= 2 || |
|
|
|
(taskResultDetail.value[0] && taskResultDetail.value[0].results && |
|
|
|
taskResultDetail.value[0].results.length <= 5)) { |
|
|
|
return [taskResultDetail.value[0]]; |
|
|
|
if (props.taskResultDetail.length <= 2 || |
|
|
|
(props.taskResultDetail[0] && props.taskResultDetail[0].results && |
|
|
|
props.taskResultDetail[0].results.length <= 5)) { |
|
|
|
return [props.taskResultDetail[0]]; |
|
|
|
} |
|
|
|
|
|
|
|
return taskResultDetail.value; |
|
|
|
return props.taskResultDetail; |
|
|
|
}); |
|
|
|
|
|
|
|
// 生成审核项的唯一key |
|
|
@ -265,20 +378,130 @@ |
|
|
|
updateActiveItemKeys(); |
|
|
|
}; |
|
|
|
|
|
|
|
// 切换项目展开/折叠 |
|
|
|
const toggleItemExpand = (categoryIndex: number, itemIndex: number, item: any) => { |
|
|
|
const itemKey = getItemKey(categoryIndex, itemIndex, item); |
|
|
|
const index = activeItemKeys.value.indexOf(itemKey); |
|
|
|
|
|
|
|
if (index > -1) { |
|
|
|
activeItemKeys.value.splice(index, 1); |
|
|
|
} else { |
|
|
|
activeItemKeys.value.push(itemKey); |
|
|
|
} |
|
|
|
}; |
|
|
|
|
|
|
|
const renderMarkdown = (text) => { |
|
|
|
if (!text) return ''; |
|
|
|
return md.render(text); |
|
|
|
|
|
|
|
// 确保在Markdown处理前将普通换行符转换为Markdown换行(添加两个空格后的换行) |
|
|
|
// 这样即使不是段落分隔的换行也能正确显示 |
|
|
|
const processedText = text.replace(/\n/g, ' \n'); |
|
|
|
return md.render(processedText); |
|
|
|
}; |
|
|
|
|
|
|
|
const [registerDrawer, { closeDrawer, setDrawerProps }] = useDrawerInner(async (data) => { |
|
|
|
setDrawerProps({ loading: true }); |
|
|
|
// 加载PDF文件 |
|
|
|
const loadPdf = async (taskId: string) => { |
|
|
|
try { |
|
|
|
const pdfBlob = await getPdfStream(taskId); |
|
|
|
const arrayBuffer = await pdfBlob.arrayBuffer(); |
|
|
|
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer }); |
|
|
|
const doc = await loadingTask.promise; |
|
|
|
if (doc) { |
|
|
|
pdfDoc = doc; |
|
|
|
totalPages.value = doc.numPages; |
|
|
|
currentPage.value = 1; |
|
|
|
await nextTick(); |
|
|
|
await renderPage(1); |
|
|
|
} |
|
|
|
} catch (error) { |
|
|
|
console.error('加载PDF失败:', error); |
|
|
|
message.error('加载PDF失败'); |
|
|
|
} |
|
|
|
}; |
|
|
|
|
|
|
|
// 渲染当前页面 |
|
|
|
const renderPage = async (num: number) => { |
|
|
|
if (!pdfDoc || !pdfCanvas.value || !pdfContainer.value) return; |
|
|
|
|
|
|
|
try { |
|
|
|
if (data?.taskResultDetail) { |
|
|
|
taskResultDetail.value = data.taskResultDetail; |
|
|
|
taskInfo.value = data.taskInfo || {}; |
|
|
|
const page = await pdfDoc.getPage(num); |
|
|
|
const canvas = pdfCanvas.value; |
|
|
|
const ctx = canvas.getContext('2d'); |
|
|
|
if (!ctx) return; |
|
|
|
|
|
|
|
// 计算自适应缩放比例,保证页面完整显示 |
|
|
|
const containerWidth = pdfContainer.value.clientWidth || 800; |
|
|
|
const baseViewport = page.getViewport({ scale: 1 }); |
|
|
|
const scaleRatio = ((containerWidth - 48) / baseViewport.width) * scale.value; // 结合自适应和用户缩放 |
|
|
|
const viewport = page.getViewport({ scale: scaleRatio }); |
|
|
|
|
|
|
|
canvas.width = viewport.width; |
|
|
|
canvas.height = viewport.height; |
|
|
|
canvas.style.width = `${viewport.width}px`; |
|
|
|
canvas.style.height = `${viewport.height}px`; |
|
|
|
|
|
|
|
// 关键:如果有上一次渲染任务,先取消 |
|
|
|
if (currentRenderTask) { |
|
|
|
try { currentRenderTask.cancel(); } catch (e) {} |
|
|
|
} |
|
|
|
|
|
|
|
const renderContext = { |
|
|
|
canvasContext: ctx, |
|
|
|
viewport: viewport, |
|
|
|
}; |
|
|
|
|
|
|
|
currentRenderTask = page.render(renderContext); |
|
|
|
await currentRenderTask.promise; |
|
|
|
currentRenderTask = null; |
|
|
|
} catch (error) { |
|
|
|
currentRenderTask = null; |
|
|
|
// 如果是RenderingCancelledException,不提示错误 |
|
|
|
if (error && error.name === 'RenderingCancelledException') { |
|
|
|
return; |
|
|
|
} |
|
|
|
console.error('渲染PDF页面失败:', error); |
|
|
|
message.error('渲染PDF页面失败'); |
|
|
|
} |
|
|
|
}; |
|
|
|
|
|
|
|
// 上一页 |
|
|
|
const prevPage = async () => { |
|
|
|
if (currentPage.value > 1) { |
|
|
|
currentPage.value--; |
|
|
|
await renderPage(currentPage.value); |
|
|
|
} |
|
|
|
}; |
|
|
|
|
|
|
|
// 下一页 |
|
|
|
const nextPage = async () => { |
|
|
|
if (currentPage.value < totalPages.value) { |
|
|
|
currentPage.value++; |
|
|
|
await renderPage(currentPage.value); |
|
|
|
} |
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
// 监听props变化,初始化数据 |
|
|
|
watch(() => props.visible, (newVal) => { |
|
|
|
if (newVal && props.taskResultDetail) { |
|
|
|
initData(); |
|
|
|
if (props.taskInfo?.id) { |
|
|
|
loadPdf(props.taskInfo.id); |
|
|
|
} |
|
|
|
} |
|
|
|
}); |
|
|
|
|
|
|
|
// 初始化数据 |
|
|
|
const initData = async () => { |
|
|
|
loading.value = true; |
|
|
|
try { |
|
|
|
if (props.taskResultDetail) { |
|
|
|
// 存储接收到的任务类型 |
|
|
|
receivedTaskType.value = props.taskInfo.taskName || ''; |
|
|
|
console.log('receivedTaskType', receivedTaskType.value); |
|
|
|
|
|
|
|
// 默认展开第一个分类 |
|
|
|
if (taskResultDetail.value.length > 0) { |
|
|
|
// 默认选中第一个分类 |
|
|
|
if (props.taskResultDetail.length > 0) { |
|
|
|
activeCollapseKeys.value = ['0']; |
|
|
|
} |
|
|
|
|
|
|
@ -288,9 +511,14 @@ |
|
|
|
} catch (error) { |
|
|
|
console.error('初始化详情抽屉时出错', error); |
|
|
|
} finally { |
|
|
|
setDrawerProps({ loading: false }); |
|
|
|
loading.value = false; |
|
|
|
} |
|
|
|
}); |
|
|
|
}; |
|
|
|
|
|
|
|
const handleClose = () => { |
|
|
|
emit('update:visible', false); |
|
|
|
emit('close'); |
|
|
|
}; |
|
|
|
|
|
|
|
async function handleStatusChange(id: string, field: 'isRead' | 'isAdopted', value: '0' | '1') { |
|
|
|
if (!id) { |
|
|
@ -304,10 +532,10 @@ |
|
|
|
|
|
|
|
try { |
|
|
|
await updateResultItemStatus(id, field, value); |
|
|
|
message.success(`状态更新成功`); |
|
|
|
// message.success(`状态更新成功`); |
|
|
|
|
|
|
|
// 更新本地状态 |
|
|
|
taskResultDetail.value.forEach(category => { |
|
|
|
props.taskResultDetail.forEach(category => { |
|
|
|
category.results.forEach(item => { |
|
|
|
if (item.id === id) { |
|
|
|
item[field] = value; |
|
|
@ -327,64 +555,179 @@ |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
function handleClose() { |
|
|
|
closeDrawer(); |
|
|
|
} |
|
|
|
|
|
|
|
// 监听状态变化,更新折叠状态 |
|
|
|
watch([expandReadItems, expandAdoptedItems], () => { |
|
|
|
updateActiveItemKeys(); |
|
|
|
}); |
|
|
|
|
|
|
|
// 复制内容到剪贴板 |
|
|
|
const copyContent = (content: any) => { |
|
|
|
if (!content) return; |
|
|
|
|
|
|
|
// 如果是对象,可能需要特殊处理 |
|
|
|
let textToCopy = ''; |
|
|
|
|
|
|
|
if (typeof content === 'object') { |
|
|
|
// 例如 reviewBasis 对象的特殊处理 |
|
|
|
if (content.reviewContent) { |
|
|
|
textToCopy = content.reviewContent; |
|
|
|
|
|
|
|
// 如果有审查要点,也添加到复制内容中 |
|
|
|
if (content.reviewPoints && content.reviewPoints.length) { |
|
|
|
textToCopy += '\n\n审查要点:\n'; |
|
|
|
content.reviewPoints.forEach((point, index) => { |
|
|
|
textToCopy += `${index + 1}. ${point}\n`; |
|
|
|
}); |
|
|
|
} |
|
|
|
} else { |
|
|
|
// 其他对象类型转为JSON字符串 |
|
|
|
try { |
|
|
|
textToCopy = JSON.stringify(content, null, 2); |
|
|
|
} catch (e) { |
|
|
|
textToCopy = String(content); |
|
|
|
} |
|
|
|
} |
|
|
|
} else { |
|
|
|
// 字符串或其他类型直接转换 |
|
|
|
textToCopy = String(content); |
|
|
|
} |
|
|
|
|
|
|
|
// 复制到剪贴板 |
|
|
|
navigator.clipboard.writeText(textToCopy) |
|
|
|
.then(() => { |
|
|
|
message.success('内容已复制到剪贴板'); |
|
|
|
}) |
|
|
|
.catch((err) => { |
|
|
|
console.error('复制失败:', err); |
|
|
|
message.error('复制失败,请手动选择并复制'); |
|
|
|
|
|
|
|
// 备用复制方法(兼容性更好但不太推荐) |
|
|
|
const textarea = document.createElement('textarea'); |
|
|
|
textarea.value = textToCopy; |
|
|
|
document.body.appendChild(textarea); |
|
|
|
textarea.select(); |
|
|
|
try { |
|
|
|
document.execCommand('copy'); |
|
|
|
message.success('内容已复制到剪贴板'); |
|
|
|
} catch (e) { |
|
|
|
console.error('备用复制方法失败:', e); |
|
|
|
message.error('复制失败,请手动选择并复制'); |
|
|
|
} |
|
|
|
document.body.removeChild(textarea); |
|
|
|
}); |
|
|
|
}; |
|
|
|
|
|
|
|
// 组件卸载前清理资源 |
|
|
|
onBeforeUnmount(() => { |
|
|
|
if (pdfDoc) { |
|
|
|
pdfDoc.destroy(); |
|
|
|
} |
|
|
|
}); |
|
|
|
|
|
|
|
onMounted(() => { |
|
|
|
// 初始化后执行的代码 |
|
|
|
}); |
|
|
|
|
|
|
|
const zoomIn = () => { |
|
|
|
if (scale.value < 3) { |
|
|
|
scale.value += 0.1; |
|
|
|
renderPage(currentPage.value); |
|
|
|
} |
|
|
|
}; |
|
|
|
|
|
|
|
const zoomOut = () => { |
|
|
|
if (scale.value > 0.5) { |
|
|
|
scale.value -= 0.1; |
|
|
|
renderPage(currentPage.value); |
|
|
|
} |
|
|
|
}; |
|
|
|
|
|
|
|
const handlePageSelect = async (page: number) => { |
|
|
|
currentPage.value = page; |
|
|
|
await renderPage(page); |
|
|
|
showPageSelectModal.value = false; |
|
|
|
// message.success(`已定位到第${page}页`); |
|
|
|
}; |
|
|
|
|
|
|
|
const handlePageModalClose = () => { |
|
|
|
showPageSelectModal.value = false; |
|
|
|
}; |
|
|
|
|
|
|
|
const locateByText = async (text: string) => { |
|
|
|
if (!pdfDoc || !text) return; |
|
|
|
const numPages = pdfDoc.numPages; |
|
|
|
const foundPages: number[] = []; |
|
|
|
for (let i = 1; i <= numPages; i++) { |
|
|
|
const page = await pdfDoc.getPage(i); |
|
|
|
const content = await page.getTextContent(); |
|
|
|
const pageText = content.items.map((item: any) => item.str).join(''); |
|
|
|
if (pageText.replace(/\s/g, '').includes(text.replace(/\s/g, ''))) { |
|
|
|
foundPages.push(i); |
|
|
|
} |
|
|
|
} |
|
|
|
if (foundPages.length === 0) { |
|
|
|
message.warning('未在PDF中找到该文本'); |
|
|
|
return; |
|
|
|
} |
|
|
|
if (foundPages.length === 1) { |
|
|
|
currentPage.value = foundPages[0]; |
|
|
|
await renderPage(foundPages[0]); |
|
|
|
message.success(`已定位到第${foundPages[0]}页`); |
|
|
|
return; |
|
|
|
} |
|
|
|
matchedPages.value = foundPages; |
|
|
|
showPageSelectModal.value = true; |
|
|
|
}; |
|
|
|
</script> |
|
|
|
|
|
|
|
<style lang="less" scoped> |
|
|
|
:deep(.ant-drawer-body) { |
|
|
|
padding: 16px; |
|
|
|
height: calc(100% - 100px); |
|
|
|
overflow: auto; |
|
|
|
height: 100%; |
|
|
|
display: flex; |
|
|
|
flex-direction: column; |
|
|
|
padding: 0; |
|
|
|
overflow: hidden; |
|
|
|
} |
|
|
|
|
|
|
|
.document-review-container { |
|
|
|
.document-review-container, |
|
|
|
.tasks-container, |
|
|
|
:deep(.ant-tabs), |
|
|
|
:deep(.ant-tabs-content), |
|
|
|
:deep(.ant-tabs-tabpane), |
|
|
|
.category-items { |
|
|
|
height: 100%; |
|
|
|
display: flex; |
|
|
|
flex-direction: column; |
|
|
|
overflow: hidden; |
|
|
|
} |
|
|
|
|
|
|
|
.tasks-container { |
|
|
|
height: 100%; |
|
|
|
overflow: auto; |
|
|
|
:deep(.ant-tabs-nav) { |
|
|
|
flex-shrink: 0; |
|
|
|
background: #fff; |
|
|
|
z-index: 2; |
|
|
|
} |
|
|
|
|
|
|
|
.tasks-collapse { |
|
|
|
margin-bottom: 16px; |
|
|
|
|
|
|
|
:deep(.ant-collapse-header) { |
|
|
|
font-weight: 500; |
|
|
|
padding: 12px 16px; |
|
|
|
background-color: #f8f8f8; |
|
|
|
} |
|
|
|
|
|
|
|
:deep(.ant-collapse-content-box) { |
|
|
|
padding: 0; |
|
|
|
} |
|
|
|
.items-card-list { |
|
|
|
flex: 1; |
|
|
|
overflow-y: auto; |
|
|
|
padding: 16px; |
|
|
|
min-height: 0; |
|
|
|
} |
|
|
|
|
|
|
|
.items-collapse { |
|
|
|
:deep(.ant-collapse-item) { |
|
|
|
margin-bottom: 12px; |
|
|
|
border: 1px solid #e8e8e8; |
|
|
|
border-radius: 4px !important; |
|
|
|
overflow: hidden; |
|
|
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); |
|
|
|
background-color: #fff; |
|
|
|
} |
|
|
|
|
|
|
|
:deep(.ant-collapse-header) { |
|
|
|
padding: 8px 16px !important; |
|
|
|
background-color: #f8f8f8; |
|
|
|
} |
|
|
|
:deep(.ant-tabs-tab) { |
|
|
|
padding: 8px 16px; |
|
|
|
font-weight: 500; |
|
|
|
} |
|
|
|
|
|
|
|
.item-card { |
|
|
|
width: 100%; |
|
|
|
margin-bottom: 10px; |
|
|
|
cursor: pointer; |
|
|
|
} |
|
|
|
|
|
|
|
.expand-icon { |
|
|
|
font-size: 12px; |
|
|
|
color: rgba(0, 0, 0, 0.45); |
|
|
|
} |
|
|
|
|
|
|
|
.item-header-content { |
|
|
@ -397,6 +740,10 @@ |
|
|
|
.item-info { |
|
|
|
display: flex; |
|
|
|
align-items: center; |
|
|
|
flex: 1; |
|
|
|
overflow: hidden; |
|
|
|
text-overflow: ellipsis; |
|
|
|
white-space: nowrap; |
|
|
|
} |
|
|
|
|
|
|
|
.item-serial { |
|
|
@ -407,13 +754,18 @@ |
|
|
|
|
|
|
|
.item-title { |
|
|
|
font-weight: 500; |
|
|
|
font-size: 16px; |
|
|
|
font-size: 14px; |
|
|
|
flex: 1; |
|
|
|
overflow: hidden; |
|
|
|
text-overflow: ellipsis; |
|
|
|
white-space: nowrap; |
|
|
|
} |
|
|
|
|
|
|
|
.item-actions { |
|
|
|
display: flex; |
|
|
|
align-items: center; |
|
|
|
margin-left: 16px; |
|
|
|
flex-shrink: 0; |
|
|
|
} |
|
|
|
|
|
|
|
.item-content { |
|
|
@ -428,6 +780,19 @@ |
|
|
|
font-weight: 500; |
|
|
|
margin-bottom: 8px; |
|
|
|
color: #333; |
|
|
|
display: flex; |
|
|
|
align-items: center; |
|
|
|
justify-content: space-between; |
|
|
|
} |
|
|
|
|
|
|
|
.copy-btn { |
|
|
|
padding: 0 4px; |
|
|
|
font-size: 12px; |
|
|
|
height: 24px; |
|
|
|
|
|
|
|
:deep(.anticon) { |
|
|
|
font-size: 12px; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
.section-content { |
|
|
@ -435,6 +800,9 @@ |
|
|
|
background-color: #f9fbfd; |
|
|
|
border-radius: 4px; |
|
|
|
padding: 12px; |
|
|
|
white-space: pre-wrap; |
|
|
|
max-height: 300px; |
|
|
|
overflow: auto; |
|
|
|
} |
|
|
|
|
|
|
|
.markdown-content { |
|
|
@ -512,4 +880,54 @@ |
|
|
|
display: flex; |
|
|
|
align-items: center; |
|
|
|
} |
|
|
|
|
|
|
|
.split-layout { |
|
|
|
display: flex; |
|
|
|
height: 100%; |
|
|
|
overflow: hidden; |
|
|
|
} |
|
|
|
|
|
|
|
.pdf-preview { |
|
|
|
width: 50%; |
|
|
|
height: 100%; |
|
|
|
border-right: 1px solid #e8e8e8; |
|
|
|
display: flex; |
|
|
|
flex-direction: column; |
|
|
|
background: #f5f5f5; |
|
|
|
} |
|
|
|
|
|
|
|
.pdf-container { |
|
|
|
flex: 1; |
|
|
|
overflow: auto; |
|
|
|
display: block; |
|
|
|
padding: 24px 0; |
|
|
|
background: #f5f5f5; |
|
|
|
|
|
|
|
canvas { |
|
|
|
background: #fff; |
|
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); |
|
|
|
border-radius: 6px; |
|
|
|
display: block; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
.pdf-controls { |
|
|
|
padding: 8px; |
|
|
|
display: flex; |
|
|
|
justify-content: center; |
|
|
|
align-items: center; |
|
|
|
background: white; |
|
|
|
border-top: 1px solid #e8e8e8; |
|
|
|
|
|
|
|
.page-info { |
|
|
|
margin: 0 16px; |
|
|
|
color: rgba(0, 0, 0, 0.65); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
.tasks-container { |
|
|
|
width: 50%; |
|
|
|
height: 100%; |
|
|
|
overflow: hidden; |
|
|
|
} |
|
|
|
</style> |