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.
 
 
 
 
 
 

1380 lines
46 KiB

<template>
<Drawer
v-bind="$attrs"
:visible="visible"
@close="handleClose"
:title="config?.name || '审核结果'"
:width="width"
:closable="true"
:maskClosable="true"
:destroyOnClose="true"
>
<div class="universal-review-container">
<div class="split-layout">
<!-- 左侧PDF预览 -->
<div class="pdf-preview-wrapper" v-if="config?.pdfConfig && pdfLayoutConfig">
<ReviewPdfContainer
ref="pdfContainerRef"
:config="pdfLayoutConfig"
:taskInfo="taskInfo"
:getPdfStream="getPdfStream"
@pdfLoaded="handlePdfLoaded"
@pdfError="handlePdfError"
/>
</div>
<!-- 右侧审核结果 -->
<div class="content-container">
<!-- 多标签模式 -->
<div v-if="config?.mode === 'tabs'" class="tabs-container">
<div class="review-type-tabs" v-if="availableTabs.length > 0">
<Tabs v-model:activeKey="currentTab" @change="handleTabChange">
<TabPane
v-for="tab in availableTabs"
:key="tab.key"
:tab="tab.label"
/>
</Tabs>
</div>
<div class="tab-content">
<div class="category-items" v-if="filteredResults.length > 0">
<div class="items-card-list">
<template v-for="(category, categoryIndex) in filteredResults" :key="categoryIndex">
<Card
v-for="(item, idx) in category.results"
:key="getItemKey(categoryIndex, idx, item)"
class="item-card"
:bordered="true"
:bodyStyle="{ padding: activeItemKeys.includes(getItemKey(categoryIndex, idx, item)) ? '16px' : '0', height: activeItemKeys.includes(getItemKey(categoryIndex, idx, item)) ? 'auto' : '0', overflow: 'hidden', transition: 'all 0.3s' }"
>
<template #title>
<div class="item-header-content" @click="toggleItemExpand(categoryIndex, 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(categoryIndex, 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(categoryIndex, idx, item))">
<template v-for="(section, sectionIndex) in getContentSections(item)" :key="sectionIndex">
<div v-if="getItemValue(item, section.field)" class="content-section">
<div class="section-title">
{{ section.title }}:
<div class="title-actions">
<Button
v-if="shouldShowComparison(section, item) && shouldShowComparisonButton(section)"
type="link"
size="small"
class="comparison-btn"
@click.stop="toggleComparison(getItemId(item, categoryIndex, idx))"
>
<EyeOutlined v-if="!comparisonVisible[getItemId(item, categoryIndex, idx)]" />
<EyeInvisibleOutlined v-else />
{{ comparisonVisible[getItemId(item, categoryIndex, idx)] ? '隐藏对比' : '显示对比' }}
</Button>
<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>
<div
class="section-content markdown-content"
v-html="renderContent(section, getItemValue(item, section.field))"
@click="locateByText(getItemValue(item, section.field), item, section, categoryIndex, idx)"
></div>
<!-- 页面按钮 - 只在有pdfSource且存在多页时显示 -->
<div
v-if="supportsPdfLocation(section) && fieldPageButtons[getFieldKey(item, section, categoryIndex, idx)]"
class="page-buttons"
>
<span class="page-buttons-label">定位到页面:</span>
<Button
v-for="page in fieldPageButtons[getFieldKey(item, section, categoryIndex, idx)]"
:key="page"
type="link"
size="small"
class="page-btn"
@click.stop="goToPageByButton(page, section)"
>
第{{ page }}页
</Button>
</div>
</div>
<!-- 对比内容展示 - 只在对比模式下显示 -->
<div
v-if="shouldShowComparison(section, item) &&
hasComparisonContent(item, section) &&
comparisonVisible[getItemId(item, categoryIndex, idx)]"
class="content-section comparison-section"
>
<div class="section-title">
{{ getComparisonTitle(section) }}:
<Button
type="link"
size="small"
class="copy-btn"
@click.stop="copyContent(getComparisonContent(item, section))"
>
<CopyOutlined /> 复制
</Button>
</div>
<div
class="section-content markdown-content comparison-content"
v-html="renderMarkdown(getComparisonContent(item, section))"
@click="locateByText(getComparisonContent(item, section), item, section, categoryIndex, idx)"
></div>
</div>
</template>
</div>
</Card>
</template>
</div>
</div>
</div>
</div>
<!-- 单一模式 -->
<div v-else class="single-content">
<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(item)" :key="sectionIndex">
<div v-if="getItemValue(item, section.field)" class="content-section">
<div class="section-title">
{{ section.title }}:
<div class="title-actions">
<Button
v-if="shouldShowComparison(section, item) && shouldShowComparisonButton(section)"
type="link"
size="small"
class="comparison-btn"
@click.stop="toggleComparison(getItemId(item, index, idx))"
>
<EyeOutlined v-if="!comparisonVisible[getItemId(item, index, idx)]" />
<EyeInvisibleOutlined v-else />
{{ comparisonVisible[getItemId(item, index, idx)] ? '隐藏对比' : '显示对比' }}
</Button>
<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>
<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), item, section, index, idx)"
></div>
<!-- 页面按钮 - 只在有pdfSource且存在多页时显示 -->
<div
v-if="supportsPdfLocation(section) && fieldPageButtons[getFieldKey(item, section, index, idx)]"
class="page-buttons"
>
<span class="page-buttons-label">定位到页面:</span>
<Button
v-for="page in fieldPageButtons[getFieldKey(item, section, index, idx)]"
:key="page"
type="link"
size="small"
class="page-btn"
@click.stop="goToPageByButton(page, section)"
>
第{{ page }}页
</Button>
</div>
</div>
<!-- 对比内容展示 - 只在对比模式下显示 -->
<div
v-if="shouldShowComparison(section, item) &&
hasComparisonContent(item, section) &&
comparisonVisible[getItemId(item, index, idx)]"
class="content-section comparison-section"
>
<div class="section-title">
{{ getComparisonTitle(section) }}:
<Button
type="link"
size="small"
class="copy-btn"
@click.stop="copyContent(getComparisonContent(item, section))"
>
<CopyOutlined /> 复制
</Button>
</div>
<div
class="section-content markdown-content comparison-content"
v-html="renderMarkdown(getComparisonContent(item, section))"
@click="locateByText(getComparisonContent(item, section), item, section, index, idx)"
></div>
</div>
</template>
</div>
</Card>
</div>
</div>
</TabPane>
</Tabs>
</div>
</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>
</Drawer>
</template>
<script lang="ts" setup>
import { ref, computed, watch, nextTick, type PropType } from 'vue';
import { Drawer, Button, Card, Switch, Tabs, TabPane } from 'ant-design-vue';
import { DownOutlined, UpOutlined, CopyOutlined, EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import MarkdownIt from 'markdown-it';
import ReviewPdfContainer from '@/components/Preview/src/PdfPreview/ReviewPdfContainer.vue';
import {
getTaskConfig,
shouldShowComparison,
getFieldPdfSource,
supportsPdfLocation,
getComparisonField,
getComparisonTitle,
shouldShowComparisonButton,
type TaskConfig,
type FieldConfig
} from '@/configs/taskConfigs';
// 定义数据接口
interface TaskResultItem {
id?: string;
serialNumber?: string;
existingIssues?: string;
isRead?: string;
isAdopted?: string;
originalText?: string;
modifiedContent?: string;
modificationDisplay?: string;
reviewBasis?: any;
comparedText?: string;
[key: string]: any;
}
interface TaskResultCategory {
name: string;
results: TaskResultItem[];
}
const props = defineProps({
visible: {
type: Boolean,
default: false
},
title: {
type: String,
default: ''
},
width: {
type: [String, Number],
default: '95%'
},
taskType: {
type: String,
required: true
},
taskResultDetail: {
type: Array as PropType<TaskResultCategory[]>,
default: () => []
},
taskInfo: {
type: Object,
default: () => ({})
},
getPdfStream: {
type: Function as PropType<(taskId: string) => Promise<Blob>>,
required: true
},
updateResultItemStatus: {
type: Function as PropType<(id: string, field: string, value: string) => Promise<void>>,
required: true
}
});
const emit = defineEmits(['update:visible', 'close']);
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
breaks: true,
});
// 获取任务配置
const config = computed(() => getTaskConfig(props.taskType));
// 基础状态管理
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 currentTab = ref<string>('');
const comparisonVisible = ref<Record<string, boolean>>({});
// 页面选择相关状态(仅保留必要的)
const currentSelectText = ref<string>('');
const currentSelectFieldConfig = ref<FieldConfig | null>(null);
const currentSelectItem = ref<TaskResultItem | null>(null);
const currentSelectCategoryIndex = ref<number | null>(null);
const currentSelectItemIndex = ref<number | null>(null);
const fieldPageButtons = ref<Record<string, number[]>>({});
const pdfContainerRef = ref<InstanceType<typeof ReviewPdfContainer> | null>(null);
// PDF布局配置(支持tabs模式下动态切换)
const pdfLayoutConfig = computed(() => {
if (!config.value) return null;
let pdfConfig = config.value.pdfConfig;
// 如果是tabs模式,优先使用当前tab的PDF配置
if (config.value.mode === 'tabs' && currentTab.value) {
// 根据当前tab名称匹配配置中的tab
// 通过数据的name(中文)匹配配置中的label
const currentTabConfig = config.value.tabs?.find(tab => tab.label === currentTab.value);
if (currentTabConfig?.pdfConfig) {
pdfConfig = currentTabConfig.pdfConfig;
}
}
if (!pdfConfig) return null;
// 创建适配ReviewTypeConfig接口的配置对象
return {
type: config.value.taskType,
name: config.value.name,
pdfLayout: pdfConfig.layout,
pdfSources: pdfConfig.sources,
fields: [] // 暂时使用空数组避免类型冲突
} as any;
});
// 可用标签页(直接基于数据生成)
const availableTabs = computed(() => {
if (config.value?.mode !== 'tabs' || !props.taskResultDetail || props.taskResultDetail.length === 0) {
return [];
}
// 在tabs模式下,跳过第一个"全部"数据,只显示实际的分类
const dataToProcess = props.taskResultDetail.slice(1); // 跳过第一个数据
// 根据剩余数据生成标签页
return dataToProcess.map((category, index) => {
let label = `${category.name} (${category.results?.length || 0})`;
// 特殊处理:如果是实质性审查,加上审查立场
if (category.name === '实质性审查' && props.taskInfo?.contractPartyRole) {
label = `${props.taskInfo.contractPartyRole})实质性审查 (${category.results?.length || 0})`;
}
return {
key: category.name, // 使用数据的name作为key
label: label,
dataIndex: index + 1, // 记录在原数据中的索引(+1因为跳过了第一个)
name: category.name
};
});
});
// 过滤后的结果(用于标签模式)
const filteredResults = computed(() => {
if (config.value?.mode !== 'tabs' || !props.taskResultDetail || !currentTab.value) {
return [];
}
// 在tabs模式下,跳过第一个"全部"数据,只从实际分类中查找
const dataToSearch = props.taskResultDetail.slice(1); // 跳过第一个数据
// 根据当前选中的tab name,找到对应的数据类别
const targetCategory = dataToSearch.find(category => category.name === currentTab.value);
if (targetCategory) {
return [targetCategory];
}
// 如果没找到对应数据,返回空数组
return [];
});
// 单一模式的过滤结果
const filteredTaskResultDetail = computed(() => {
if (config.value?.mode === 'tabs') return [];
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;
});
// 获取当前字段配置
const getContentSections = (item: TaskResultItem): FieldConfig[] => {
if (config.value?.mode === 'tabs') {
// 多标签模式:根据当前标签页名称匹配配置中的tab
// 通过数据的name(中文)匹配配置中的label
const currentTabConfig = config.value.tabs?.find(tab => tab.label === currentTab.value);
return (currentTabConfig?.fields || []).filter(field => {
if (field.displayCondition) {
return field.displayCondition(item);
}
return getItemValue(item, field.field);
});
} else {
// 单一模式:使用配置的字段
return (config.value?.fields || []).filter(field => {
if (field.displayCondition) {
return field.displayCondition(item);
}
return getItemValue(item, field.field);
});
}
};
// 从项目中获取特定字段的值
const getItemValue = (item: TaskResultItem, field: string): any => {
if (!item || !field) return null;
// 支持嵌套字段
if (field.includes('.')) {
const parts = field.split('.');
let value: any = item;
for (const part of parts) {
if (value && typeof value === 'object') {
value = value[part];
} else if (part === 'reviewBasis' && typeof value === 'string') {
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: FieldConfig, value: any): string => {
if (!value) return '';
if (section.displayType === 'markdown') {
if (section.dataType === 'json' && section.jsonConfig) {
return renderJsonContent(value, section.jsonConfig);
}
return renderMarkdown(value);
}
return value.toString().replace(/\n/g, '<br>');
};
// 渲染JSON内容
const renderJsonContent = (value: any, jsonConfig: any): string => {
let jsonObj = value;
// 如果是字符串,尝试解析为JSON
if (typeof value === 'string') {
try {
jsonObj = JSON.parse(value);
} catch (e) {
return value.toString().replace(/\n/g, '<br>');
}
}
const values: string[] = [];
const separator = jsonConfig.separator || ':';
jsonConfig.extractFields.forEach((fieldName: string) => {
if (jsonObj[fieldName]) {
let fieldValue = jsonObj[fieldName];
// 应用字段处理器
if (jsonConfig.fieldProcessors && jsonConfig.fieldProcessors[fieldName]) {
fieldValue = jsonConfig.fieldProcessors[fieldName](fieldValue);
} else {
// 默认处理:如果是数组取第一个元素
if (Array.isArray(fieldValue) && fieldValue.length > 0) {
fieldValue = fieldValue[0];
}
}
if (fieldValue) {
values.push(String(fieldValue));
}
}
});
return renderMarkdown(values.join(separator));
};
const renderMarkdown = (text: any) => {
if (!text) return '';
const processedText = String(text).replace(/\n/g, ' \n');
return md.render(processedText);
};
// 生成审核项的唯一key
const getItemKey = (categoryIndex: number, itemIndex: number, item: TaskResultItem): string => {
return item.id ? String(item.id) : `${categoryIndex}-${itemIndex}`;
};
// 获取项目的唯一标识符
const getItemId = (item: TaskResultItem, categoryIndex: number, itemIndex: number): string => {
return item.id ? String(item.id) : getItemKey(categoryIndex, itemIndex, item);
};
// 切换对比显示状态
const toggleComparison = (itemId: string) => {
comparisonVisible.value[itemId] = !comparisonVisible.value[itemId];
};
// 切换项目展开/折叠
const toggleItemExpand = (categoryIndex: number, itemIndex: number, item: TaskResultItem) => {
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 updateActiveItemKeys = () => {
const newActiveKeys: string[] = [];
const dataToProcess = config.value?.mode === 'tabs' ? filteredResults.value : filteredTaskResultDetail.value;
dataToProcess.forEach((category: TaskResultCategory, categoryIndex: number) => {
if (category?.results) {
category.results.forEach((item: TaskResultItem, itemIndex: number) => {
const itemKey = getItemKey(categoryIndex, itemIndex, item);
const isRead = item.isRead === '1';
const isAdopted = item.isAdopted === '1';
const shouldExpand =
(!isRead && !isAdopted) ||
(isRead && expandReadItems.value) ||
(isAdopted && expandAdoptedItems.value);
if (shouldExpand) {
newActiveKeys.push(itemKey);
}
});
}
});
activeItemKeys.value = newActiveKeys;
};
// 切换展开已读项
const toggleExpandReadItems = (checked: any) => {
expandReadItems.value = !!checked;
updateActiveItemKeys();
};
// 切换展开已采纳项
const toggleExpandAdoptedItems = (checked: any) => {
expandAdoptedItems.value = !!checked;
updateActiveItemKeys();
};
// 标签页切换
const handleTabChange = (activeKey: string | number) => {
currentTab.value = String(activeKey);
nextTick(() => {
updateActiveItemKeys();
// 通知PDF容器配置已变化
if (pdfContainerRef.value) {
pdfContainerRef.value.$forceUpdate && pdfContainerRef.value.$forceUpdate();
}
});
};
// 状态变更处理
const handleStatusChange = async (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 props.updateResultItemStatus(id, field, value);
// 更新本地状态
const dataToUpdate = config.value?.mode === 'tabs' ? props.taskResultDetail : filteredTaskResultDetail.value;
dataToUpdate.forEach((category: TaskResultCategory) => {
if (category?.results) {
category.results.forEach((item: TaskResultItem) => {
if (item.id === id) {
item[field] = value;
}
});
}
});
updateActiveItemKeys();
} catch (error) {
console.error('更新状态失败', error);
message.error('更新状态失败');
} finally {
loading.value = false;
currentOpId.value = '';
currentOpField.value = '';
}
};
// 复制内容到剪贴板
const copyContent = (content: any) => {
if (!content) return;
let textToCopy = '';
if (typeof content === 'string') {
try {
const parsed = JSON.parse(content);
if (parsed && typeof parsed === 'object') {
const values: string[] = [];
const fields = ['reviewPoints', 'reviewContent', 'review_content'];
fields.forEach(fieldName => {
if (parsed[fieldName]) {
let fieldValue = parsed[fieldName];
if (Array.isArray(fieldValue) && fieldValue.length > 0) {
fieldValue = fieldValue[0];
}
if (fieldValue) {
values.push(String(fieldValue));
}
}
});
if (values.length > 0) {
textToCopy = values.join(':');
} else {
textToCopy = content;
}
} else {
textToCopy = content;
}
} catch (e) {
textToCopy = content;
}
} else if (typeof content === 'object') {
if (content.reviewContent) {
textToCopy = content.reviewContent;
if (content.reviewPoints && content.reviewPoints.length) {
textToCopy += '\n\n审查要点:\n';
content.reviewPoints.forEach((point: any, index: number) => {
textToCopy += `${index + 1}. ${point}\n`;
});
}
} else {
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 getFieldKey = (item: TaskResultItem, fieldConfig: FieldConfig, categoryIndex: number, itemIndex: number): string => {
const itemId = getItemId(item, categoryIndex, itemIndex);
return `${itemId}_${fieldConfig.field}`;
};
// 定位功能 - 优化版,利用现有弹窗机制并保存页面信息
const locateByText = async (text: string, item?: TaskResultItem, fieldConfig?: FieldConfig, categoryIndex?: number, itemIndex?: number) => {
if (!pdfContainerRef.value || !text || !text.trim()) return;
// 检查字段是否支持PDF定位
if (fieldConfig && !supportsPdfLocation(fieldConfig)) {
console.log('Field does not support PDF location:', fieldConfig.field);
return;
}
// 获取PDF源
let pdfSource: string | undefined;
if (fieldConfig) {
const source = getFieldPdfSource(fieldConfig);
pdfSource = source || undefined;
}
// 保存当前定位的字段信息,以便在检测到多页面时使用
if (fieldConfig && item && categoryIndex !== undefined && itemIndex !== undefined) {
currentSelectText.value = text;
currentSelectFieldConfig.value = fieldConfig;
currentSelectItem.value = item;
currentSelectCategoryIndex.value = categoryIndex;
currentSelectItemIndex.value = itemIndex;
// 清理当前字段的页面按钮数据,重新获取
const fieldKey = getFieldKey(item, fieldConfig, categoryIndex, itemIndex);
console.log('fieldKey', fieldKey);
delete fieldPageButtons.value[fieldKey];
console.log('fieldPageButtons', fieldPageButtons.value);
}
// 检查是否已经有页面按钮,如果有直接使用第一个页面
if (fieldConfig && item && categoryIndex !== undefined && itemIndex !== undefined) {
const fieldKey = getFieldKey(item, fieldConfig, categoryIndex, itemIndex);
if (fieldPageButtons.value[fieldKey] && fieldPageButtons.value[fieldKey].length > 0) {
// 有页面按钮时,使用第一个页面进行定位
try {
await pdfContainerRef.value.locateByText(text, pdfSource);
return;
} catch (error) {
console.error('PDF定位失败:', error);
}
}
}
try {
// 先尝试用完整文本进行定位
await pdfContainerRef.value.locateByText(text, pdfSource);
// 延迟检查是否出现了页面选择弹窗
setTimeout(() => {
checkForPageModal();
}, 300); // 等待弹窗显示
} catch (error) {
console.error('完整文本定位失败,尝试使用前20个字符定位:', error);
// 如果完整文本定位失败,尝试用前10个字符再次定位
const shortText = text.trim().substring(0,25);
if (shortText && shortText !== text.trim()) {
try {
console.log('使用前25个字符进行定位:', shortText);
message.warning('完整文本定位失败,尝试使用前25个字符定位');
await pdfContainerRef.value.locateByText(shortText, pdfSource);
// 延迟检查是否出现了页面选择弹窗
setTimeout(() => {
checkForPageModal();
}, 300); // 等待弹窗显示
} catch (shortError) {
console.error('前25个字符定位也失败:', shortError);
message.warning('文本定位失败,该内容可能不在PDF中');
}
} else {
message.warning('文本定位失败,该内容可能不在PDF中');
}
}
};
// 通过页面按钮跳转
const goToPageByButton = async (page: number, fieldConfig: FieldConfig) => {
if (!pdfContainerRef.value) return;
// 获取PDF源
let pdfSource: string | undefined;
if (fieldConfig) {
const source = getFieldPdfSource(fieldConfig);
pdfSource = source || undefined;
}
// 使用PDF定位功能,传入当前选中的文本
if (currentSelectText.value) {
try {
// 使用新的直接定位方法,避免弹窗选择
await pdfContainerRef.value.locateByTextAndPage(currentSelectText.value, page, pdfSource);
message.success(`已定位到第${page}`);
} catch (error) {
console.error('页面跳转失败:', error);
message.error('页面跳转失败');
}
}
};
// 检测并提取页面选择弹窗中的页面信息
const checkForPageModal = () => {
const modal = document.querySelector('.ant-modal:not(.ant-modal-hidden)');
if (modal && modal.textContent?.includes('请选择要跳转的页码')) {
// 提取页面信息
const pages: number[] = [];
const modalButtons = document.querySelectorAll('.ant-modal:not(.ant-modal-hidden) .ant-btn');
modalButtons.forEach(button => {
const text = button.textContent || '';
console.log('button', button.textContent);
const match = text.match(/第(\d+)页/);
if (match) {
pages.push(parseInt(match[1]));
}
});
console.log('pages', pages);
// 如果有字段信息,创建页面按钮
if (pages.length > 0 && currentSelectFieldConfig.value && currentSelectItem.value &&
currentSelectCategoryIndex.value !== null && currentSelectItemIndex.value !== null) {
const fieldKey = getFieldKey(
currentSelectItem.value,
currentSelectFieldConfig.value,
currentSelectCategoryIndex.value,
currentSelectItemIndex.value
);
// 保存页面信息到状态变量
fieldPageButtons.value[fieldKey] = pages;
}
}
};
// 初始化数据
const initData = async () => {
loading.value = true;
try {
if (props.taskResultDetail) {
if (config.value?.mode === 'tabs' && availableTabs.value.length > 0) {
// 使用第一个数据类别的名称作为默认tab
currentTab.value = availableTabs.value[0].key;
} else {
if (props.taskResultDetail.length > 0) {
activeCollapseKeys.value = ['0'];
}
}
await nextTick();
updateActiveItemKeys();
}
} catch (error) {
console.error('初始化详情抽屉时出错', error);
} finally {
loading.value = false;
}
};
// 事件处理
const handleClose = () => {
// 清理页面按钮状态
fieldPageButtons.value = {};
currentSelectText.value = '';
currentSelectFieldConfig.value = null;
currentSelectItem.value = null;
currentSelectCategoryIndex.value = null;
currentSelectItemIndex.value = null;
emit('update:visible', false);
emit('close');
};
const handlePdfLoaded = (data: any) => {
console.log('PDF加载成功:', data);
};
const handlePdfError = (error: any) => {
console.error('PDF加载失败:', error);
};
// 监听变化
watch(() => props.visible, (newVal) => {
if (newVal && props.taskResultDetail) {
initData();
}
});
watch([expandReadItems, expandAdoptedItems], () => {
updateActiveItemKeys();
});
// 监听PDF配置变化,当配置变化时强制刷新PDF容器
watch(() => pdfLayoutConfig.value, (newConfig, oldConfig) => {
if (newConfig && oldConfig &&
(newConfig.pdfLayout !== oldConfig.pdfLayout ||
JSON.stringify(newConfig.pdfSources) !== JSON.stringify(oldConfig.pdfSources))) {
nextTick(() => {
if (pdfContainerRef.value) {
// 强制PDF容器重新渲染
pdfContainerRef.value.$forceUpdate && pdfContainerRef.value.$forceUpdate();
}
});
}
}, { deep: true });
// 检查是否有对比内容(支持配置化对比字段)
const hasComparisonContent = (item: TaskResultItem, fieldConfig: FieldConfig): boolean => {
const comparisonField = getComparisonField(fieldConfig);
return !!(item[comparisonField] && item[comparisonField].trim());
};
// 获取对比内容
const getComparisonContent = (item: TaskResultItem, fieldConfig: FieldConfig): string => {
const comparisonField = getComparisonField(fieldConfig);
return item[comparisonField] || '';
};
</script>
<style lang="less" scoped>
:deep(.ant-drawer-body) {
height: 100%;
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
}
.universal-review-container,
.content-container,
.tabs-container,
.single-content,
: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;
}
.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;
}
.title-actions {
display: flex;
align-items: center;
gap: 8px;
}
.comparison-btn {
padding: 0 8px;
font-size: 12px;
height: 24px;
border: 1px solid #d9d9d9;
border-radius: 4px;
:deep(.anticon) {
font-size: 12px;
margin-right: 4px;
}
&:hover {
border-color: #40a9ff;
color: #40a9ff;
}
}
.copy-btn {
padding: 0 4px;
font-size: 12px;
height: 24px;
:deep(.anticon) {
font-size: 12px;
}
}
.section-content {
padding: 12px;
background-color: #f9fbfd;
border-radius: 4px;
white-space: pre-wrap;
max-height: 300px;
overflow: auto;
}
.comparison-section {
margin-top: 12px;
border-top: 1px dashed #e8e8e8;
padding-top: 12px;
}
.comparison-content {
background-color: #fff7e6;
border: 1px solid #ffd591;
border-radius: 4px;
&:hover {
background-color: #fff1d6;
}
}
.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;
}
}
}
.ml-3 {
margin-left: 12px;
}
.page-buttons {
margin-top: 8px;
padding: 8px 12px;
background-color: #f0f9ff;
border: 1px solid #bfdbfe;
border-radius: 4px;
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.page-buttons-label {
font-size: 12px;
color: #6b7280;
margin-right: 4px;
}
.page-btn {
font-size: 12px;
height: 24px;
padding: 0 8px;
background-color: #ffffff;
border: 1px solid #d1d5db;
border-radius: 3px;
margin: 0 2px;
&:hover {
background-color: #dbeafe;
border-color: #3b82f6;
color: #1d4ed8;
}
}
.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-wrapper {
width: 50%;
height: 100%;
border-right: 1px solid #e8e8e8;
}
.content-container {
width: 50%;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.review-type-tabs {
flex-shrink: 0;
border-bottom: 1px solid #e8e8e8;
background: #fff;
:deep(.ant-tabs-nav) {
margin-bottom: 0;
}
:deep(.ant-tabs-tab) {
padding: 8px 16px;
font-weight: 500;
color: #666;
&.ant-tabs-tab-active {
color: #1890ff;
font-weight: 600;
}
}
}
.tab-content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
</style>