You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1090 lines
32 KiB
1090 lines
32 KiB
<template>
|
|
<Drawer
|
|
v-bind="$attrs"
|
|
:visible="visible"
|
|
@close="handleClose"
|
|
:title="title"
|
|
:width="width"
|
|
:closable="true"
|
|
:maskClosable="true"
|
|
:destroyOnClose="true"
|
|
>
|
|
<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">
|
|
<Tabs v-model:activeKey="activeCollapseKeys[0]">
|
|
<TabPane
|
|
v-for="(category, index) in filteredTaskResultDetail"
|
|
:key="index.toString()"
|
|
:tab="category.name + ' (' + category.results.length + ')'"
|
|
>
|
|
<div class="category-items">
|
|
<div class="items-card-list">
|
|
<Card
|
|
v-for="(item, idx) in category.results"
|
|
: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 #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.existingIssues }}</span>
|
|
</div>
|
|
<div class="item-actions">
|
|
<Switch
|
|
:checked="item.isRead === '1'"
|
|
checked-children="已读"
|
|
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"
|
|
:checked="item.isAdopted === '1'"
|
|
checked-children="已采纳"
|
|
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" 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 }}:
|
|
<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 v-if="section.field === 'reviewBasis' && section.type === 'reviewPointsList'" class="section-content markdown-content">
|
|
<div v-if="getParsedReviewBasis(item.reviewBasis)?.review_points?.length">
|
|
<!-- <ul class="review-points">
|
|
<li v-for="(point, pointIdx) in getParsedReviewBasis(item.reviewBasis).review_points" :key="pointIdx">
|
|
{{ point }}
|
|
</li>
|
|
</ul> -->
|
|
</div>
|
|
<div v-if="getParsedReviewBasis(item.reviewBasis)?.review_content">
|
|
{{ getParsedReviewBasis(item.reviewBasis).review_points[0] }}:{{ getParsedReviewBasis(item.reviewBasis).review_content }}
|
|
<!-- <div v-html="renderMarkdown(getParsedReviewBasis(item.reviewBasis).review_content)" @click="locateByText(getParsedReviewBasis(item.reviewBasis).review_content)"></div> -->
|
|
</div>
|
|
</div>
|
|
<!-- 其他普通字段 -->
|
|
<div
|
|
v-else
|
|
class="section-content markdown-content"
|
|
v-html="renderContent(section, getItemValue(item, section.field))"
|
|
@click="locateByText(getItemValue(item, section.field))"
|
|
></div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</TabPane>
|
|
</Tabs>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<template #footer>
|
|
<div class="drawer-footer">
|
|
<div class="status-switches">
|
|
<Switch
|
|
:checked="expandReadItems"
|
|
checked-children="展开已读"
|
|
un-checked-children="折叠已读"
|
|
@change="(checked: any) => toggleExpandReadItems(checked)"
|
|
/>
|
|
<Switch
|
|
class="ml-3"
|
|
:checked="expandAdoptedItems"
|
|
checked-children="展开已采纳"
|
|
un-checked-children="折叠已采纳"
|
|
@change="(checked: any) => toggleExpandAdoptedItems(checked)"
|
|
/>
|
|
</div>
|
|
<Button type="primary" @click="handleClose">关闭</Button>
|
|
</div>
|
|
</template>
|
|
<PageSelectModal
|
|
:visible="showPageSelectModal"
|
|
:pages="matchedPages"
|
|
:onSelect="handlePageSelect"
|
|
:onClose="handlePageModalClose"
|
|
/>
|
|
</Drawer>
|
|
</template>
|
|
|
|
<script lang="ts" setup>
|
|
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 { ContractualTaskResultDetailVO } from '@/api/contractReview/ContractualTaskResults/model';
|
|
import { updateResultItemStatus, getPdfStream } from '@/api/contractReview/ContractualTaskResults';
|
|
import { message } 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 '@/views/documentReview/DocumentTasks/PageSelectModal.vue';
|
|
|
|
// 设置PDF.js worker路径
|
|
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.js';
|
|
|
|
const props = defineProps({
|
|
visible: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
title: {
|
|
type: String,
|
|
default: '合同审核结果'
|
|
},
|
|
width: {
|
|
type: [String, Number],
|
|
default: '95%'
|
|
},
|
|
taskResultDetail: {
|
|
type: Array as PropType<ContractualTaskResultDetailVO[]>,
|
|
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[]>(['0']);
|
|
const activeItemKeys = ref<string[]>([]);
|
|
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; // 记录当前渲染任务
|
|
|
|
const highlightText = ref<string | null>(null);
|
|
|
|
// 内容区域配置,定义不同类型任务显示哪些内容
|
|
interface ContentSectionConfig {
|
|
field: string;
|
|
title: string;
|
|
type?: 'markdown' | 'text' | 'reviewPointsList';
|
|
nestedFields?: string[];
|
|
}
|
|
|
|
// 全局默认配置,所有类型都默认显示这些字段
|
|
const defaultContentSections: ContentSectionConfig[] = [
|
|
{ field: 'originalText', title: '原文', type: 'markdown' },
|
|
{ field: 'comparedText', title: '比对原文', type: 'markdown' },
|
|
{ field: 'modifiedContent', title: '修改后内容', type: 'markdown' },
|
|
{ field: 'modificationDisplay', title: '修改情况', type: 'markdown' },
|
|
{ field: 'existingIssues', title: '存在问题', type: 'markdown' },
|
|
{
|
|
field: 'reviewBasis',
|
|
title: '审查依据',
|
|
type: 'reviewPointsList',
|
|
nestedFields: ['reviewContent', 'reviewPoints']
|
|
}
|
|
];
|
|
|
|
// 不同类型任务的特定配置,可以覆盖或扩展默认配置
|
|
const taskTypeContentConfig: Record<string, ContentSectionConfig[]> = {
|
|
|
|
"contractualReview": [
|
|
{ field: 'originalText', title: '原文', type: 'markdown' },
|
|
{ field: 'modifiedContent', title: '修改建议', type: 'markdown' },
|
|
{ field: 'modificationDisplay', title: '修改情况展示', type: 'markdown' },
|
|
// { field: 'existingIssues', title: '问题描述', type: 'markdown' },
|
|
{
|
|
field: 'reviewBasis',
|
|
title: '审查依据',
|
|
type: 'reviewPointsList',
|
|
nestedFields: ['reviewContent', 'reviewPoints']
|
|
}
|
|
],
|
|
};
|
|
|
|
// 根据任务类型获取内容配置
|
|
const getContentSections = (taskType: string | undefined, item: any): ContentSectionConfig[] => {
|
|
// 如果未提供类型或类型不存在,使用默认配置
|
|
if (!taskType) return defaultContentSections;
|
|
// 如果存在特定类型的配置则使用,否则使用默认配置
|
|
return taskTypeContentConfig[taskType] || defaultContentSections;
|
|
};
|
|
|
|
// 从项目中获取特定字段的值
|
|
const getItemValue = (item: any, field: string): any => {
|
|
if (!item || !field) return null;
|
|
|
|
// 支持嵌套字段 如 "reviewBasis.reviewContent"
|
|
if (field.includes('.')) {
|
|
const parts = field.split('.');
|
|
let value = item;
|
|
for (const part of parts) {
|
|
if (value && typeof value === 'object') {
|
|
value = value[part];
|
|
} else if (part === 'reviewBasis' && typeof value === 'string') {
|
|
// 特殊处理:如果是reviewBasis字段且为字符串,尝试解析JSON
|
|
try {
|
|
value = JSON.parse(value);
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
return value;
|
|
}
|
|
|
|
// 如果直接访问reviewBasis字段且为字符串,尝试解析
|
|
if (field === 'reviewBasis' && typeof item[field] === 'string') {
|
|
try {
|
|
return JSON.parse(item[field]);
|
|
} catch (e) {
|
|
// 如果解析失败,返回原始字符串
|
|
return item[field];
|
|
}
|
|
}
|
|
|
|
return item[field];
|
|
};
|
|
|
|
// 根据配置渲染内容
|
|
const renderContent = (section: ContentSectionConfig, value: any): string => {
|
|
if (!value) return '';
|
|
|
|
if (section.type === 'markdown') {
|
|
return renderMarkdown(value);
|
|
}
|
|
|
|
if (section.type === 'reviewPointsList' && section.nestedFields) {
|
|
let content = '';
|
|
|
|
// 处理reviewBasis字段
|
|
if (section.field === 'reviewBasis') {
|
|
let reviewBasisObj = value;
|
|
|
|
// 如果value是字符串,尝试解析为JSON
|
|
if (typeof value === 'string') {
|
|
try {
|
|
reviewBasisObj = JSON.parse(value);
|
|
} catch (e) {
|
|
// 如果解析失败,直接返回字符串内容
|
|
return value.toString().replace(/\n/g, '<br>');
|
|
}
|
|
}
|
|
|
|
// 渲染review_content部分
|
|
if (section.nestedFields.includes('reviewContent') && reviewBasisObj.review_content) {
|
|
content += renderMarkdown(reviewBasisObj.review_content);
|
|
}
|
|
|
|
return content;
|
|
}
|
|
|
|
// 其他reviewPointsList类型的处理
|
|
if (section.nestedFields.includes('reviewContent') && value.review_content) {
|
|
content += renderMarkdown(value.review_content);
|
|
}
|
|
|
|
return content;
|
|
}
|
|
|
|
return value.toString().replace(/\n/g, '<br>');
|
|
};
|
|
|
|
// 根据条件过滤展示的结果
|
|
const filteredTaskResultDetail = computed(() => {
|
|
if (props.taskResultDetail.length <= 0) {
|
|
return [];
|
|
}
|
|
|
|
// 如果只有两个或更少的类别,或者第一个类别的results数量<=5个,只展示第一个类别
|
|
if (props.taskResultDetail.length <= 2 ||
|
|
(props.taskResultDetail[0] && props.taskResultDetail[0].results &&
|
|
props.taskResultDetail[0].results.length <= 5)) {
|
|
return [props.taskResultDetail[0]];
|
|
}
|
|
|
|
return props.taskResultDetail;
|
|
});
|
|
|
|
// 生成审核项的唯一key
|
|
const getItemKey = (categoryIndex: number, itemIndex: number, item: any) => {
|
|
return item.id ? item.id : `${categoryIndex}-${itemIndex}`;
|
|
};
|
|
|
|
// 根据过滤条件更新折叠状态
|
|
const updateActiveItemKeys = () => {
|
|
const newActiveKeys: string[] = [];
|
|
|
|
filteredTaskResultDetail.value.forEach((category, categoryIndex) => {
|
|
category.results.forEach((item, itemIndex) => {
|
|
const itemKey = getItemKey(categoryIndex, itemIndex, item);
|
|
const isRead = item.isRead === '1';
|
|
const isAdopted = item.isAdopted === '1';
|
|
|
|
// 如果项目未读且未采纳,或符合展开条件,则展开
|
|
if ((!isRead && !isAdopted) ||
|
|
(isRead && expandReadItems.value) ||
|
|
(isAdopted && expandAdoptedItems.value)) {
|
|
newActiveKeys.push(itemKey);
|
|
}
|
|
});
|
|
});
|
|
|
|
activeItemKeys.value = newActiveKeys;
|
|
};
|
|
|
|
// 切换展开已读项
|
|
const toggleExpandReadItems = (checked: any) => {
|
|
expandReadItems.value = !!checked;
|
|
updateActiveItemKeys();
|
|
};
|
|
|
|
// 切换展开已采纳项
|
|
const toggleExpandAdoptedItems = (checked: any) => {
|
|
expandAdoptedItems.value = !!checked;
|
|
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 '';
|
|
|
|
// 确保在Markdown处理前将普通换行符转换为Markdown换行(添加两个空格后的换行)
|
|
// 这样即使不是段落分隔的换行也能正确显示
|
|
const processedText = text.replace(/\n/g, ' \n');
|
|
return md.render(processedText);
|
|
};
|
|
|
|
// 加载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, highlightStr?: string) => {
|
|
if (!pdfDoc || !pdfCanvas.value || !pdfContainer.value) return;
|
|
|
|
try {
|
|
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;
|
|
|
|
// ====== 高亮文本实现 ======
|
|
if (highlightStr) {
|
|
await highlightTextOnPage(page, ctx, viewport, highlightStr);
|
|
}
|
|
} 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 (props.taskResultDetail.length > 0) {
|
|
activeCollapseKeys.value = ['0'];
|
|
}
|
|
|
|
// 初始化项目折叠状态
|
|
updateActiveItemKeys();
|
|
}
|
|
} catch (error) {
|
|
console.error('初始化详情抽屉时出错', error);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const handleClose = () => {
|
|
emit('update:visible', false);
|
|
emit('close');
|
|
};
|
|
|
|
async function handleStatusChange(id: string, field: 'isRead' | 'isAdopted', value: '0' | '1') {
|
|
if (!id) {
|
|
message.error('缺少记录ID,无法更新状态');
|
|
return;
|
|
}
|
|
|
|
loading.value = true;
|
|
currentOpId.value = id;
|
|
currentOpField.value = field;
|
|
|
|
try {
|
|
await updateResultItemStatus(id, field, value);
|
|
// message.success(`状态更新成功`);
|
|
|
|
// 更新本地状态
|
|
props.taskResultDetail.forEach(category => {
|
|
category.results.forEach(item => {
|
|
if (item.id === id) {
|
|
item[field] = value;
|
|
}
|
|
});
|
|
});
|
|
|
|
// 更新折叠状态
|
|
updateActiveItemKeys();
|
|
} catch (error) {
|
|
console.error('更新状态失败', error);
|
|
message.error('更新状态失败');
|
|
} finally {
|
|
loading.value = false;
|
|
currentOpId.value = '';
|
|
currentOpField.value = '';
|
|
}
|
|
}
|
|
|
|
// 监听状态变化,更新折叠状态
|
|
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, highlightText.value ?? undefined);
|
|
showPageSelectModal.value = false;
|
|
message.success(`已定位到第${page}页`);
|
|
};
|
|
|
|
const handlePageModalClose = () => {
|
|
showPageSelectModal.value = false;
|
|
};
|
|
|
|
const locateByText = async (text: string) => {
|
|
if (!pdfDoc || !text) return;
|
|
|
|
// 去除Markdown格式中的**标记
|
|
const cleanText = text.replace(/\*\*/g, '');
|
|
|
|
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('');
|
|
|
|
// 去除空格后比较文本
|
|
// 标准化文本,移除所有非文本字符
|
|
const normalizeText = (text: string) => {
|
|
return text
|
|
.replace(/[\r\n\t\f\v]/g, '') // 移除所有换行和不可见字符
|
|
.replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, '') // 只保留中文、英文和数字
|
|
.toLowerCase(); // 转换为小写以进行不区分大小写的比较
|
|
};
|
|
|
|
const normalizedPageText = normalizeText(pageText);
|
|
const normalizedSearchText = normalizeText(cleanText);
|
|
|
|
if (normalizedPageText.includes(normalizedSearchText)) {
|
|
foundPages.push(i);
|
|
}
|
|
}
|
|
|
|
if (foundPages.length === 0) {
|
|
message.warning('未在PDF中找到该文本');
|
|
return;
|
|
}
|
|
|
|
// 保存要高亮的文本(去除**标记后的)
|
|
highlightText.value = cleanText;
|
|
|
|
// 如果只找到一个页面,直接跳转并高亮
|
|
if (foundPages.length === 1) {
|
|
currentPage.value = foundPages[0];
|
|
await renderPage(foundPages[0], cleanText);
|
|
message.success(`已定位到第${foundPages[0]}页`);
|
|
return;
|
|
}
|
|
|
|
// 如果找到多个页面,弹出选择对话框
|
|
matchedPages.value = foundPages;
|
|
showPageSelectModal.value = true;
|
|
};
|
|
|
|
// 高亮整段文本
|
|
async function highlightTextOnPage(page, ctx, viewport, targetText) {
|
|
if (!targetText) return;
|
|
|
|
// 确保去除**标记
|
|
const cleanTargetText = targetText.replace(/\*\*/g, '');
|
|
|
|
const textContent = await page.getTextContent();
|
|
const items = textContent.items as any[];
|
|
|
|
// 连接所有文本项,但不移除空格,以保持更好的匹配精度
|
|
const pageTextWithSpaces = items.map(i => i.str).join('');
|
|
const pageText = pageTextWithSpaces.replace(/\s/g, '');
|
|
const target = cleanTargetText.replace(/\s/g, '');
|
|
|
|
// 尝试在页面文本中查找目标文本
|
|
const startIdx = pageText.indexOf(target);
|
|
if (startIdx === -1) return;
|
|
|
|
// 反查属于哪些item
|
|
let charCount = 0;
|
|
let highlightItems: any[] = [];
|
|
let highlightStarted = false;
|
|
let highlightLength = 0;
|
|
|
|
for (let item of items) {
|
|
const itemTextNoSpace = item.str.replace(/\s/g, '');
|
|
if (!highlightStarted && charCount + itemTextNoSpace.length > startIdx) {
|
|
highlightStarted = true;
|
|
}
|
|
|
|
if (highlightStarted && highlightLength < target.length) {
|
|
highlightItems.push(item);
|
|
highlightLength += itemTextNoSpace.length;
|
|
if (highlightLength >= target.length) break;
|
|
}
|
|
|
|
charCount += itemTextNoSpace.length;
|
|
}
|
|
|
|
// 绘制高亮
|
|
ctx.save();
|
|
ctx.globalAlpha = 0.4;
|
|
ctx.fillStyle = '#ffd54f';
|
|
|
|
for (let item of highlightItems) {
|
|
const transform = item.transform;
|
|
const x = transform[4]; // e
|
|
const y = transform[5]; // f
|
|
|
|
// 通过viewport变换
|
|
const pt = viewport.convertToViewportPoint(x, y);
|
|
|
|
// pdf.js的y是基线,需调整
|
|
const height = item.height || item.fontSize || 10;
|
|
const width = item.width || (item.str.length * (item.fontSize || 10) * 0.6);
|
|
|
|
ctx.fillRect(pt[0], pt[1] - height, width, height + 2); // 稍微扩大高亮区域以便更容易看见
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
const getParsedReviewBasis = (reviewBasis: any) => {
|
|
if (typeof reviewBasis === 'string') {
|
|
try {
|
|
return JSON.parse(reviewBasis);
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
} else if (typeof reviewBasis === 'object') {
|
|
return reviewBasis;
|
|
} else {
|
|
return null;
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<style lang="less" scoped>
|
|
:deep(.ant-drawer-body) {
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
:deep(.ant-tabs-nav) {
|
|
flex-shrink: 0;
|
|
background: #fff;
|
|
z-index: 2;
|
|
}
|
|
|
|
.items-card-list {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 16px;
|
|
min-height: 0;
|
|
}
|
|
|
|
:deep(.ant-tabs-tab) {
|
|
padding: 8px 16px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.item-card {
|
|
width: 100%;
|
|
margin-bottom: 10px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
// 覆盖Ant Design Card的默认标题样式,确保文本可以换行
|
|
.item-card :deep(.ant-card-head-title) {
|
|
overflow: visible !important;
|
|
white-space: normal !important;
|
|
text-overflow: unset !important;
|
|
}
|
|
|
|
.expand-icon {
|
|
font-size: 12px;
|
|
color: rgba(0, 0, 0, 0.45);
|
|
}
|
|
|
|
.item-header-content {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
width: 100%;
|
|
}
|
|
|
|
.item-info {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
flex: 1;
|
|
overflow: visible;
|
|
text-overflow: unset;
|
|
white-space: normal;
|
|
}
|
|
|
|
.item-serial {
|
|
margin-right: 12px;
|
|
font-weight: bold;
|
|
color: #1890ff;
|
|
}
|
|
|
|
.item-title {
|
|
font-weight: 500;
|
|
font-size: 14px;
|
|
flex: 1;
|
|
overflow: visible;
|
|
text-overflow: unset;
|
|
white-space: normal;
|
|
word-wrap: break-word;
|
|
word-break: break-word;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.item-actions {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
margin-left: 16px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.item-content {
|
|
padding: 16px;
|
|
}
|
|
|
|
.content-section {
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.section-title {
|
|
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 {
|
|
padding: 0 0 0 24px;
|
|
background-color: #f9fbfd;
|
|
border-radius: 4px;
|
|
padding: 12px;
|
|
white-space: pre-wrap;
|
|
max-height: 300px;
|
|
overflow: auto;
|
|
}
|
|
|
|
.markdown-content {
|
|
:deep(p) {
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
:deep(ul), :deep(ol) {
|
|
padding-left: 24px;
|
|
}
|
|
|
|
:deep(h1), :deep(h2), :deep(h3), :deep(h4), :deep(h5), :deep(h6) {
|
|
margin-top: 16px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
:deep(code) {
|
|
background-color: #f5f5f5;
|
|
padding: 2px 4px;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
:deep(pre) {
|
|
background-color: #f5f5f5;
|
|
padding: 12px;
|
|
border-radius: 4px;
|
|
overflow: auto;
|
|
}
|
|
|
|
:deep(blockquote) {
|
|
border-left: 4px solid #ddd;
|
|
padding-left: 16px;
|
|
color: #666;
|
|
margin: 16px 0;
|
|
}
|
|
|
|
:deep(table) {
|
|
border-collapse: collapse;
|
|
width: 100%;
|
|
margin: 16px 0;
|
|
|
|
th, td {
|
|
border: 1px solid #e8e8e8;
|
|
padding: 8px 12px;
|
|
}
|
|
|
|
th {
|
|
background-color: #f8f8f8;
|
|
font-weight: 500;
|
|
}
|
|
}
|
|
}
|
|
|
|
.review-points {
|
|
list-style-type: disc;
|
|
padding-left: 24px;
|
|
|
|
li {
|
|
margin-bottom: 4px;
|
|
}
|
|
}
|
|
|
|
.review-points-title,
|
|
.review-content-title {
|
|
font-weight: 600;
|
|
color: #1890ff;
|
|
margin-bottom: 8px;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.ml-3 {
|
|
margin-left: 12px;
|
|
}
|
|
|
|
.drawer-footer {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
width: 100%;
|
|
}
|
|
|
|
.status-switches {
|
|
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>
|