Browse Source

新增前端预览

ai_dev_new
zhouhaibin 4 weeks ago
parent
commit
3297ef8168
  1. 3
      package.json
  2. 22
      public/pdf.worker.min.js
  3. 19
      src/api/documentReview/DocumentTaskResults/index.ts
  4. 33
      src/views/documentReview/DocumentTasks/DocumentTasksTable.vue
  5. 45
      src/views/documentReview/DocumentTasks/PageSelectModal.vue
  6. 608
      src/views/documentReview/DocumentTasks/ResultDetailDrawer copy.vue
  7. 463
      src/views/documentReview/DocumentTasks/ResultDetailDrawer.vue
  8. 10
      vite.config.ts

3
package.json

@ -187,7 +187,8 @@
"markdown-it": "^13.0.2",
"markdown-it-anchor": "^8.6.7",
"markdown-it-toc-done-right": "^4.2.0",
"vue-pdf-embed":"2.1.2"
"pdfjs-dist":"2.16.105",
"@types/pdfjs-dist":"2.10.378"
},
"engines": {

22
public/pdf.worker.min.js

File diff suppressed because one or more lines are too long

19
src/api/documentReview/DocumentTaskResults/index.ts

@ -182,3 +182,22 @@ export function DocumentTaskResultDownload(id: ID | IDS) {
// return defHttp.get<void>({ url: '/productManagement/DocumentTaskResults/downloadResult/' + id ,responseType: 'blob',headers: { 'Content-Type': ContentTypeEnum.FORM_URLENCODED }},);
}
/**
* PDF文件流
* @param taskId ID
* @returns Promise<Blob>
*/
export function getPdfStream(taskId: ID): Promise<Blob> {
return defHttp.get(
{
url: `/productManagement/DocumentTaskResults/getPdfStream/${taskId}`,
responseType: 'blob',
timeout:600000
},
{
isReturnNativeResponse: true,
errorMessageMode: 'none',
}
).then(response => response.data);
}

33
src/views/documentReview/DocumentTasks/DocumentTasksTable.vue

@ -108,7 +108,13 @@
</BasicTable>
<DocumentTasksModal @register="registerModal" @reload="reload" />
<DocsDrawer @register="registerDrawer" />
<ResultDetailDrawer @register="registerResultDetailDrawer" />
<ResultDetailDrawer
:visible="resultDetailDrawerVisible"
:taskResultDetail="taskResultDetail"
:taskInfo="currentTaskInfo"
@update:visible="resultDetailDrawerVisible = $event"
@close="handleResultDetailDrawerClose"
/>
</template>
<script setup lang="ts">
@ -134,9 +140,10 @@
defineOptions({ name: 'DocumentTasks' });
const documentData = ref<DocumentTasksPermissionsVO>();
const [registerDrawer, { openDrawer }] = useDrawer();
const [registerResultDetailDrawer, { openDrawer: openResultDetailDrawer }] = useDrawer();
const resultDetailDrawerVisible = ref(false);
const childTableData = ref([]);
const taskResultDetail = ref<DocumentTaskResultDetailVO[]>([]);
const currentTaskInfo = ref<Recordable>({});
const [registerTable, { reload }] = useTable({
api: DocumentTasksList,
showIndexColumn: false,
@ -205,20 +212,20 @@
return content.trim();
};
const handleResultDetailDrawerClose = () => {
resultDetailDrawerVisible.value = false;
};
async function handleDetail(record: Recordable) {
try {
let res = await DocumentTaskResultsInfoByTaskId(record.id);
// API使API
console.info("resresres",res,res.result,!res,!res.result)
if (!res || !res.result) {
try {
const detailRes = await getDetailResultsByTaskId(record.id);
if (detailRes && detailRes.length > 0) {
taskResultDetail.value = detailRes;
openResultDetailDrawer(true, {
taskResultDetail: detailRes,
taskInfo: record
});
currentTaskInfo.value = record;
resultDetailDrawerVisible.value = true;
return;
}
} catch (detailEx) {
@ -226,30 +233,24 @@
}
}
//
if (record.taskName == 'schemEvaluation') {
const updatedHtmlText = res.result?.replace(
/文件名称:\S+/g,
`文件名称:${record.documentName}`,
);
openDrawer(true, { value: cleanHtml(updatedHtmlText), type: 'markdown' });
} else if (record.taskName == 'checkDocumentError') {
openDrawer(true, { value: res.result, type: 'markdown' });
} else {
openDrawer(true, { value: res.result, type: 'markdown' });
}
console.log('res', res);
} catch (ex) {
// API使API
try {
const detailRes = await getDetailResultsByTaskId(record.id);
if (detailRes && detailRes.length > 0) {
taskResultDetail.value = detailRes;
openResultDetailDrawer(true, {
taskResultDetail: detailRes,
taskInfo: record,
});
currentTaskInfo.value = record;
resultDetailDrawerVisible.value = true;
return;
}
} catch (detailEx) {

45
src/views/documentReview/DocumentTasks/PageSelectModal.vue

@ -0,0 +1,45 @@
<template>
<Modal
:visible="visible"
title="请选择要跳转的页码"
@cancel="onClose"
:footer="null"
width="400"
maskClosable
closable
>
<p>该文本在以下页面出现</p>
<div style="display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px;">
<Button
v-for="page in pages"
:key="page"
type="primary"
size="small"
style="margin-bottom: 4px;"
@click="() => handleSelect(page)"
>
{{ page }}
</Button>
</div>
</Modal>
</template>
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue';
import { Modal, Button } from 'ant-design-vue';
const props = defineProps({
visible: Boolean,
pages: {
type: Array as () => number[],
default: () => []
},
onSelect: Function,
onClose: Function
});
const emit = defineEmits(['select', 'close']);
function handleSelect(page: number) {
props.onSelect && props.onSelect(page);
emit('select', page);
}
</script>

608
src/views/documentReview/DocumentTasks/ResultDetailDrawer copy.vue

@ -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' },
],
// checkDocumentErrorschemEvaluation
};
//
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);
// MarkdownMarkdown
// 使
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>

463
src/views/documentReview/DocumentTasks/ResultDetailDrawer.vue

@ -1,14 +1,35 @@
<template>
<BasicDrawer
<Drawer
v-bind="$attrs"
@register="registerDrawer"
showFooter
title="文档审核结果"
width="50%"
: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">
<Tabs v-model:activeKey="activeCollapseKeys[0]">
<TabPane
@ -55,7 +76,6 @@
</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">
@ -70,8 +90,12 @@
<CopyOutlined /> 复制
</Button>
</div>
<div class="section-content markdown-content" v-html="renderContent(section, getItemValue(item, section.field))"></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">
@ -90,7 +114,7 @@
</Tabs>
</div>
</div>
</template>
</div>
<template #footer>
<div class="drawer-footer">
<div class="status-switches">
@ -111,18 +135,54 @@
<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, nextTick } 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, DocumentTaskResultDownload } from '@/api/documentReview/DocumentTaskResults';
import { message, Alert, Button, Card } from 'ant-design-vue';
import { Switch, Divider, Tabs, TabPane } from 'ant-design-vue';
import { DownOutlined, UpOutlined, CopyOutlined } from '@ant-design/icons-vue';
import { updateResultItemStatus, getPdfStream } from '@/api/documentReview/DocumentTaskResults';
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 './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<DocumentTaskResultDetailVO[]>,
default: () => []
},
taskInfo: {
type: Object,
default: () => ({})
}
});
const emit = defineEmits(['update:visible', 'close']);
const md = new MarkdownIt({
html: true,
@ -133,8 +193,6 @@
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>('');
@ -142,6 +200,21 @@
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;
@ -254,18 +327,18 @@
//
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
@ -328,19 +401,114 @@
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, 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 (data?.taskResultDetail) {
if (props.taskResultDetail) {
//
receivedTaskType.value = data.taskInfo.taskName || '';
receivedTaskType.value = props.taskInfo.taskName || '';
console.log('receivedTaskType', receivedTaskType.value);
// categorytype
taskResultDetail.value = data.taskResultDetail;
taskInfo.value = data.taskInfo || {};
//
if (taskResultDetail.value.length > 0) {
if (props.taskResultDetail.length > 0) {
activeCollapseKeys.value = ['0'];
}
@ -350,9 +518,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) {
@ -369,7 +542,7 @@
// message.success(``);
//
taskResultDetail.value.forEach(category => {
props.taskResultDetail.forEach(category => {
category.results.forEach(item => {
if (item.id === id) {
item[field] = value;
@ -389,10 +562,6 @@
}
}
function handleClose() {
closeDrawer();
}
//
watch([expandReadItems, expandAdoptedItems], () => {
updateActiveItemKeys();
@ -455,29 +624,146 @@
});
};
//
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!);
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;
}
highlightText.value = text; //
if (foundPages.length === 1) {
currentPage.value = foundPages[0];
await renderPage(foundPages[0], text);
// message.success(`${foundPages[0]}`);
return;
}
matchedPages.value = foundPages;
showPageSelectModal.value = true;
};
//
async function highlightTextOnPage(page, ctx, viewport, targetText) {
const textContent = await page.getTextContent();
const items = textContent.items as any[];
const allText = items.map(i => i.str).join('').replace(/\s/g, '');
const target = targetText.replace(/\s/g, '');
const startIdx = allText.indexOf(target);
if (startIdx === -1) return;
// item
let charCount = 0;
let highlightItems: any[] = [];
let highlightStarted = false;
let highlightLength = 0;
for (let item of items) {
if (!highlightStarted && charCount + item.str.length > startIdx) {
highlightStarted = true;
}
if (highlightStarted && highlightLength < targetText.length) {
highlightItems.push(item);
highlightLength += item.str.length;
if (highlightLength >= targetText.length) break;
}
charCount += item.str.length;
}
//
ctx.save();
ctx.globalAlpha = 0.4;
ctx.fillStyle = '#ffd54f';
for (let item of highlightItems) {
const [a, b, c, d, e, f] = item.transform;
//
const x = e;
const y = f;
// viewport
const pt = viewport.convertToViewportPoint(x, y);
// pdf.jsy线
const height = item.height || item.fontSize || 10;
ctx.fillRect(pt[0], pt[1] - height, item.width, height);
}
ctx.restore();
}
</script>
<style lang="less" scoped>
:deep(.ant-drawer-body) {
padding: 16px;
height: calc(100% - 100px);
overflow: auto;
}
.document-review-container {
height: 100%;
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
}
.tasks-container {
.document-review-container,
.tasks-container,
:deep(.ant-tabs),
:deep(.ant-tabs-content),
:deep(.ant-tabs-tabpane),
.category-items {
height: 100%;
overflow: auto;
display: flex;
flex-direction: column;
overflow: hidden;
}
:deep(.ant-tabs-nav) {
margin-bottom: 16px;
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) {
@ -485,31 +771,6 @@
font-weight: 500;
}
.items-card-list {
:deep(.ant-card) {
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-card-head) {
padding: 0 !important;
background-color: #f8f8f8;
min-height: 40px;
.ant-card-head-title {
padding: 8px 16px !important;
}
}
:deep(.ant-card-body) {
transition: all 0.3s ease;
}
}
.item-card {
width: 100%;
margin-bottom: 10px;
@ -671,4 +932,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>

10
vite.config.ts

@ -12,8 +12,18 @@ export default defineApplicationConfig({
'@iconify/iconify',
'ant-design-vue/es/locale/zh_CN',
'ant-design-vue/es/locale/en_US',
'pdfjs-dist'
],
},
build: {
rollupOptions: {
output: {
manualChunks: {
'pdfjs': ['pdfjs-dist']
}
}
}
},
server: {
proxy: {
'/basic-api': {

Loading…
Cancel
Save