提交 7da93833 authored 作者: yanpeng's avatar yanpeng

问题修复

上级 3f53461a
......@@ -33,7 +33,7 @@ const financeRoutes = [
},
// V2.0单条制裁详情-实体清单原文
{
path: "/exportControl/origin",
path: "/finance/origin",
name: "financeEntityListOrigin",
component: () => import("@/views/finance/singleSanction/originPage/index.vue")
// meta: {
......
......@@ -476,12 +476,6 @@
header-row-class-name="table-header"
row-class-name="table-row"
>
<!-- <el-table-column prop="index" label="序号" width="80" align="center">
<template #default="scope">
{{ scope.$index + 1 + (currentPage - 1) * pageSize }}
</template>
</el-table-column> -->
<el-table-column prop="name" label="实体名称" min-width="200">
<template #default="scope">
<div class="tableName" @click="handleCompClick(scope.row)">
......@@ -970,7 +964,7 @@ onMounted(async () => {
label: item.nameZh,
value: tabMap[item.id],
id: [item.id],
disabled: item.id == "13" // 商业管制清单不展示
disabled: false
}));
resourceTabs.value.unshift({ label: "全部制裁", value: "all", id: "", disabled: false });
console.log("返回的数据结构 infoList =》", infoList.value);
......@@ -1108,7 +1102,7 @@ const processYearDomainCountData = yearDomainCountData => {
const handleEntityClick = item => {
console.log("item", item);
window.sessionStorage.setItem("curTabName", item.name || item.entityNameZh);
gotoCompanyPages(item.entityId);
gotoCompanyPages(item.id);
// const route = router.resolve({
// name: "companyPages",
// params: {
......@@ -1494,7 +1488,7 @@ watch(
const fetchEntitiesList = async (page = 1, size = 10) => {
try {
console.log("activeResourceTabItem.value.id", activeResourceTabItem.value.id);
const res = await getEntitiesList(activeResourceTabItem.value.id, page, size);
const res = await getEntitiesList(activeResourceTabItem.value.id.join(","), page, size);
if (res) {
entitiesList.value = res.content.map(item => ({
...item,
......@@ -1630,6 +1624,7 @@ const fetchSocialMediaInfo = async () => {
if (data && Array.isArray(data)) {
// console.log(data);
socialMediaList.value = data.map(item => ({
...item,
avatar: item.personImage,
name: item.personName,
time: formatTime(item.time),
......@@ -1662,7 +1657,7 @@ const fetchNewsInfo = async () => {
};
const handlePerClick = item => {
// console.log("点击了社交媒体消息:", item);
console.log("点击了社交媒体消息:", item);
window.sessionStorage.setItem("curTabName", item.name);
const route = router.resolve({
path: "/characterPage",
......
......@@ -75,7 +75,7 @@
</template>
<div class="right-main">
<div class="right-main-content">
<div class="hintWrap">
<!-- <div class="hintWrap">
<div class="icon1"></div>
<div class="title">
2025年实体清单制裁范围扩大至芯片制造环节,为中国的芯片制造能力划定“技术天花板”,阻止其向更先进水平发展。制裁范围向上游设备和材料、下游先进封装以及关键工具(如EDA软件)延伸,意图瓦解中国构建自主可控产业链的努力。
......@@ -83,7 +83,7 @@
<div class="icon2Wrap">
<div class="icon2"></div>
</div>
</div>
</div> -->
<div class="right-main-content-main">
<div class="fishbone-wrapper">
<div class="fishbone-scroll-container" ref="scrollContainerRef">
......
......@@ -433,11 +433,6 @@ onMounted(() => {
</script>
<style scoped lang="scss">
* {
margin: 0;
padding: 0;
}
.introduction-page {
width: 1601px;
margin: 0 auto;
......
......@@ -155,15 +155,8 @@ const getTagStyle = tag => {
// 跳转公司详情页
const handleCompClick = item => {
console.log("item", item);
window.sessionStorage.setItem("curTabName", item.entityNameZh || item.entityName);
window.sessionStorage.setItem("curTabName", item.name || item.orgName);
gotoCompanyPages(item.id);
// const route = router.resolve({
// name: "companyPages",
// params: {
// id: item.id
// }
// });
// window.open(route.href, "_blank");
};
</script>
......
......@@ -540,10 +540,13 @@ watch(customDateRange, () => {
border: 1px solid rgba(170, 173, 177, 1);
background-color: #fff;
border-radius: 3px;
border: 1px solid #ddd;
border-radius: 4px;
height: 32px;
}
:deep(.el-input__inner) {
font-size: 16px;
font-size: 14px;
font-weight: 400;
font-family: "Microsoft YaHei";
line-height: 24px;
......@@ -552,8 +555,8 @@ watch(customDateRange, () => {
}
.filters {
display: flex;
align-items: center;
// display: flex;
// align-items: center;
.el-checkbox {
margin-right: 20px;
......@@ -565,8 +568,9 @@ watch(customDateRange, () => {
height: 32px;
}
:deep(.el-checkbox__label) {
font-size: 16px;
font-size: 14px;
color: rgb(95, 101, 108);
margin-top: 3px;
}
}
}
......@@ -596,9 +600,10 @@ watch(customDateRange, () => {
padding-left: 20px;
.el-checkbox {
width: 50%;
margin-right: 0;
margin-bottom: 4px;
// width: 50%;
// margin-right: 0;
// margin-bottom: 4px;
height: 24px;
font-size: 16px;
font-weight: 400;
font-family: "Microsoft YaHei";
......
......@@ -537,26 +537,30 @@ const domainChartOption = ref({
width: 1.1
}
},
// labelLayout: function (params) {
// const isLeft = params.labelRect.x < params.viewWidth / 2;
// const points = params.labelLinePoints;
// // Update the end point.
// points[2][0] = isLeft ? params.labelRect.x : params.labelRect.x + params.labelRect.width;
// return {
// labelLinePoints: points
// };
// },
labelLayout: function (params) {
const isLeft = params.labelRect.x < params.viewWidth / 2;
// hideOverlap: true
const points = params.labelLinePoints;
// Update the end point.
const isLeft = params.labelRect.x < params.rect.x + params.rect.width / 2;
// 调整指示线终点到 label 垂直中心
const labelCenterY = params.labelRect.y + params.labelRect.height / 2;
points[2][1] = labelCenterY;
// 调整指示线终点到 label 水平边缘
points[2][0] = isLeft ? params.labelRect.x : params.labelRect.x + params.labelRect.width;
return {
labelLinePoints: points
};
// if (isLeft) {
// // 左侧:终点对齐到标签左边界
// points[2][0] = 0;
// } else {
// // 右侧:终点对齐到标签右边界
// points[2][0] = params.labelRect.x + params.labelRect.width;
// }
// return {
// labelLinePoints: points
// };
},
itemStyle: {
borderWidth: 0
......@@ -714,11 +718,27 @@ const typeChartOption = ref({
width: 1
}
},
// labelLayout: function (params) {
// const isLeft = params.labelRect.x < params.viewWidth / 2;
// const points = params.labelLinePoints;
// // Update the end point.
// points[2][0] = isLeft ? params.labelRect.x : params.labelRect.x + params.labelRect.width;
// return {
// labelLinePoints: points
// };
// },
labelLayout: function (params) {
const isLeft = params.labelRect.x < params.viewWidth / 2;
// hideOverlap: true
const points = params.labelLinePoints;
// Update the end point.
const isLeft = params.labelRect.x < params.rect.x + params.rect.width / 2;
// 调整指示线终点到 label 垂直中心
const labelCenterY = params.labelRect.y + params.labelRect.height / 2;
points[2][1] = labelCenterY;
// 调整指示线终点到 label 水平边缘
points[2][0] = isLeft ? params.labelRect.x : params.labelRect.x + params.labelRect.width;
return {
labelLinePoints: points
};
......
......@@ -540,11 +540,6 @@ onMounted(async () => {
</script>
<style scoped lang="scss">
* {
margin: 0;
padding: 0;
}
.deepMiningChartmode {
height: calc(100vh - 220px) !important;
// overflow: hidden;
......@@ -628,6 +623,9 @@ onMounted(async () => {
.search-input {
flex: 1;
border: 1px solid #ddd;
border-radius: 4px;
height: 32px;
}
}
......
......@@ -51,7 +51,7 @@
<div class="left-main">
<div class="top-bar">
<el-select v-model="searchDomain" placeholder="全部领域" class="domain-select">
<el-option label="全部领域" value="" />
<el-option label="全部领域" value="0" />
<el-option label="人工智能" value="1" />
<el-option label="生物科技" value="2" />
<el-option label="新一代信息技术" value="3" />
......@@ -515,7 +515,7 @@ const getNetProfitData = async () => {
// 单次制裁-影响分析-制裁企业列表
const entityList = ref([]);
const searchDomain = ref("");
const searchDomain = ref("0");
const searchKeyword = ref("");
const selectedCompanyId = ref(null);
const sanRecordId = ref("");
......@@ -584,7 +584,7 @@ const getEntityList = async () => {
const res = await getSingleSanctionEntityList({
sanRecordId: sanRecordId.value,
isOnlyCn: false,
domainId: searchDomain.value || undefined,
domainId: searchDomain.value == 0 ? undefined : searchDomain.value || undefined,
searchText: searchKeyword.value || undefined
});
if (res.code === 200) {
......@@ -990,11 +990,6 @@ onMounted(async () => {
</script>
<style scoped lang="scss">
* {
margin: 0;
padding: 0;
}
.industrial-impact {
width: 100%;
padding-top: 16px;
......@@ -1056,7 +1051,8 @@ onMounted(async () => {
:deep(.el-input__wrapper) {
height: 32px;
border-radius: 4px;
border: 1px solid rgba(170, 173, 177, 0.4);
border-radius: 5px;
}
}
}
......
......@@ -1118,11 +1118,6 @@ onBeforeUnmount(() => {
</script>
<style scoped lang="scss">
* {
margin: 0;
padding: 0;
}
.industrial-impact {
width: 100%;
padding-top: 16px;
......
......@@ -28,10 +28,6 @@ const activeIndex = ref(0);
</script>
<style scoped lang="scss">
* {
margin: 0;
padding: 0;
}
.impact-analysis {
width: 1601px;
margin: 0 auto;
......
......@@ -100,10 +100,10 @@
<el-checkbox v-model="onlyChina" label="只看中国实体" />
<el-select
v-model="filterField"
placeholder="全部领域"
placeholder="选择领域"
style="width: 150px; margin: 0 12px 0 16px"
>
<el-option label="全部领域" value="" />
<!-- <el-option label="全部领域" value="" /> -->
<el-option
v-for="item in domainOptions"
:key="item.value"
......@@ -243,6 +243,24 @@ import { useGotoCompanyPages } from "@/router/modules/company";
const gotoCompanyPages = useGotoCompanyPages();
const route = useRoute();
const domainOptions = ref([
{ label: "全部领域", value: "0" },
{ label: "人工智能", value: "1" },
{ label: "生物科技", value: "2" },
{ label: "新一代信息技术", value: "3" },
{ label: "量子科技", value: "4" },
{ label: "新能源", value: "5" },
{ label: "集成电路", value: "6" },
{ label: "海洋", value: "7" },
{ label: "先进制造", value: "8" },
{ label: "新材料", value: "9" },
{ label: "航空航天", value: "10" },
{ label: "深海", value: "11" },
{ label: "极地", value: "12" },
{ label: "太空", value: "13" },
{ label: "核", value: "14" }
]);
// 跳转公司详情页
const handleCompClick = item => {
if (!item.entityId) {
......@@ -297,7 +315,7 @@ const getSanctionOverviewList = async () => {
const res = await getSingleSanctionOverviewList({
sanRecordId: sanRecordId.value,
isOnlyCn: onlyChina.value,
domainId: filterField.value || undefined,
domainId: filterField.value == 0 ? undefined : filterField.value || undefined,
searchText: searchKeyword.value || undefined,
searchType: searchType.value,
entityTypeId: filterEntity.value || undefined
......@@ -465,7 +483,7 @@ const formattedData = computed(() => {
const filterEntity = ref("2");
const onlyChina = ref(false);
const filterField = ref("");
const filterField = ref(domainOptions.value[0].value);
const searchKeyword = ref("");
const activeTab = ref("add");
const searchType = computed(() => activeTab.value);
......@@ -484,23 +502,6 @@ watch(searchKeyword, () => {
debouncedSearch();
});
const domainOptions = ref([
{ label: "人工智能", value: "1" },
{ label: "生物科技", value: "2" },
{ label: "新一代信息技术", value: "3" },
{ label: "量子科技", value: "4" },
{ label: "新能源", value: "5" },
{ label: "集成电路", value: "6" },
{ label: "海洋", value: "7" },
{ label: "先进制造", value: "8" },
{ label: "新材料", value: "9" },
{ label: "航空航天", value: "10" },
{ label: "深海", value: "11" },
{ label: "极地", value: "12" },
{ label: "太空", value: "13" },
{ label: "核", value: "14" }
]);
const tagColors = [
{ bg: "rgb(242, 235, 255)", border: "rgb(211, 190, 255)", text: "rgb(114, 46, 209)" }, // Purple
{ bg: "rgb(225, 250, 248)", border: "rgb(178, 242, 238)", text: "rgb(16, 178, 166)" }, // Cyan
......@@ -570,11 +571,6 @@ onMounted(() => {
</script>
<style scoped lang="scss">
// * {
// margin: 0;
// padding: 0;
// }
.sanctions-overview {
width: 1601px;
margin: 0 auto;
......@@ -888,19 +884,19 @@ onMounted(() => {
margin-bottom: 20px;
:deep(.el-input__inner) {
font-size: 16px;
font-size: 14px;
font-weight: 400;
font-family: "Microsoft YaHei";
line-height: 24px;
color: rgb(95, 101, 108);
color: #606266;
&::placeholder {
color: rgb(95, 101, 108);
color: rgb(95, 101, 108, 0.8);
}
}
:deep(.el-checkbox__label) {
font-size: 16px;
font-size: 14px;
font-weight: 400;
font-family: "Microsoft YaHei";
line-height: 24px;
......
......@@ -60,7 +60,9 @@
:class="{ active: selectedSanctionId === item.id }"
@click="selectSanction(item)"
>
<div class="sanction-name">
{{ item.name }}
</div>
<div class="sanction-type">{{ item.postDate }}</div>
</div>
</div>
......@@ -108,6 +110,7 @@ import router from "@/router";
// 处理点击实体清单原文按钮
const handleClickOriginalText = () => {
// 打开新标签页
window.open(`/exportControl/origin?id=${sanRecordId.value}`, "_blank");
};
......@@ -206,13 +209,14 @@ const totalElements = ref(0);
const openSanctionModal = async () => {
sanctionModalVisible.value = true;
console.log("制裁事件列表11:", sanctionList.value);
loading.value = true;
await fetchSanctionData();
};
// ========== 获取制裁数据 ==========
const fetchSanctionData = async () => {
try {
loading.value = true;
const res = await getSanctionProcess([1], currentPage.value, 10);
loading.value = false;
if (res && !!res.content) {
......@@ -254,6 +258,7 @@ const selectSanction = async item => {
sanctionModalVisible.value = false;
console.log("跳转URL:", window.location.href);
// 根据最新URL参数刷新当前页面
window.sessionStorage.setItem("curTabName", item.postDate + " 《实体清单新增条目》");
window.open(`${window.location.pathname}?id=${item.id}&sanTypeId=${item.sanTypeId}`, "_self");
};
......@@ -274,15 +279,11 @@ onMounted(() => {
</script>
<style scoped lang="scss">
* {
margin: 0;
padding: 0;
}
.entity-list {
width: 100%;
height: 100%;
overflow-y: auto;
padding-bottom: 50px;
padding-bottom: 0px;
.header {
width: 100%;
height: 148px;
......@@ -464,8 +465,15 @@ onMounted(() => {
.sanction-list {
max-height: 400px;
min-height: 400px;
overflow-y: auto;
padding: 0 10px;
.sanction-name {
max-width: 85%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.sanction-item {
......
......@@ -15,26 +15,21 @@
<div class="header-right">
<!-- 中英文切换开关 -->
<div class="toggle-group">
<span :class="{ active: !showChinese }">英文</span>
<el-switch
v-model="showChinese"
active-text="中"
inactive-text="英"
:inline-prompt="true"
@change="handleToggleChange"
/>
<span :class="{ active: showChinese }">中文</span>
<!-- <span :class="{ active: !showChinese }">英文</span> -->
<el-switch v-model="showChinese" @change="handleToggleChange" />
<img :src="transIcon" alt="" />
<span :class="{ active: showChinese }">显示原文</span>
</div>
<!-- 下载按钮 -->
<el-button type="primary" :icon="Download" @click="handleDownload"> 下载 </el-button>
<el-button plain :icon="Download" @click="handleDownload"> 下载 </el-button>
</div>
</div>
<!-- 外层滚动容器,统一控制两侧滚动 -->
<div class="report-box" ref="reportBoxRef">
<div class="pdf-pane-wrap" :class="{ 'center-mode': !showChinese }">
<div class="pdf-pane-wrap" v-if="showChinese" :class="{ 'center-mode': !showChinese }">
<pdf ref="leftPdfRef" :pdfUrl="headerTitle.srcUrl" class="pdf-pane-inner" />
</div>
<div class="pdf-pane-wrap" v-if="showChinese">
<div class="pdf-pane-wrap">
<pdf ref="rightPdfRef" :pdfUrl="headerTitle.transUrl" class="pdf-pane-inner" />
</div>
</div>
......@@ -47,6 +42,7 @@ import { ref, onMounted, watch, computed } from "vue";
import { Download } from "@element-plus/icons-vue";
import { getSingleSanctionOverview } from "@/api/exportControlV2.0.js";
import title from "../assets/title.png";
import transIcon from "../assets/icon-translation.png";
import pdf from "./pdf.vue";
const leftPdfRef = ref(null);
......@@ -299,15 +295,15 @@ onMounted(() => {
}
}
:deep(.el-button) {
--el-button-bg-color: #055fc2;
--el-button-border-color: #055fc2;
--el-button-hover-bg-color: #044c9b;
--el-button-hover-border-color: #044c9b;
// :deep(.el-button) {
// --el-button-bg-color: #055fc2;
// --el-button-border-color: #055fc2;
// --el-button-hover-bg-color: #044c9b;
// --el-button-hover-border-color: #044c9b;
font-size: 14px;
padding: 10px 20px;
}
// font-size: 14px;
// padding: 10px 20px;
// }
}
}
......@@ -318,6 +314,8 @@ onMounted(() => {
display: flex;
overflow-y: auto;
overflow-x: hidden;
// ✅ 添加居中对齐
justify-content: center;
}
.pdf-pane-wrap {
......@@ -330,6 +328,8 @@ onMounted(() => {
&.center-mode {
flex: 0 0 100%;
max-width: 100%;
// ✅ 添加居中样式
width: 728px; // 约一半宽度,保持单栏时美观
margin: 0 auto;
}
}
......
<template>
<div class="entity-list">
<div class="wrap">
<div class="header">
<div class="header-title">
<div class="header-top">
<div class="header-top-left">
<img :src="headerTitle.img" alt="" />
<div>
<div class="title">{{ headerTitle.title }}</div>
<div class="department">{{ headerTitle.department }}</div>
<div class="en-title">
{{ headerTitle.sanTitle }}
</div>
</div>
</div>
<div class="header-top-right">
<div class="image-name-box">
<div class="image"><img :src="headerTitle.postOrgLogoUrl" alt="" /></div>
<div class="name">{{ headerTitle.postOrgName }}</div>
</div>
<div class="time">{{ headerTitle.postDate }}</div>
</div>
</div>
</div>
<div class="main">
<div class="main-header">
<div class="header-left">实体清单制裁文件</div>
<div class="header-right">
<!-- 中英文切换开关 -->
<div class="toggle-group">
<!-- <span :class="{ active: !showChinese }">英文</span> -->
<el-switch v-model="showChinese" @change="handleToggleChange" />
<img :src="transIcon" alt="" />
<span :class="{ active: showChinese }">显示原文</span>
<div>实体清单制裁文件</div>
<div class="btn-box">
<div class="translate">
<div class="search-input-wrap" v-if="showSearchInput">
<input
v-model="searchKeywordText"
class="search-input"
placeholder="回车查询"
@keyup.enter="handleSearchInPdf"
/>
<div class="search-match-count">{{ matchInfo.current }}/{{ matchInfo.total }}</div>
<button
class="search-nav-btn"
type="button"
@click="handlePrevMatch"
:disabled="matchInfo.total === 0 || matchInfo.current <= 1"
>
上一个
</button>
<button
class="search-nav-btn"
type="button"
@click="handleNextMatch"
:disabled="matchInfo.total === 0 || matchInfo.current >= matchInfo.total"
>
下一个
</button>
</div>
<div class="switch">
<el-switch v-model="valueSwitch" />
</div>
<div class="translate-image">
<img
class="translate-icon"
src="../assets/icon-translation.png"
alt=""
style="
width: 16px;
height: 16px;
max-width: 16px;
max-height: 16px;
display: block;
object-fit: contain;
"
/>
</div>
<div class="translate-text">{{ "显示原文" }}</div>
</div>
<!-- 下载按钮 -->
<el-button plain :icon="Download" @click="handleDownload"> 下载 </el-button>
<div class="btn" @click="handleDownload">
<div class="icon">
<img src="../assets/image-pdf.png" alt="" />
</div>
<div class="text">{{ "下载" }}</div>
</div>
<!-- 外层滚动容器,统一控制两侧滚动 -->
<div class="report-box" ref="reportBoxRef">
<div class="pdf-pane-wrap" v-if="showChinese" :class="{ 'center-mode': !showChinese }">
<pdf ref="leftPdfRef" :pdfUrl="headerTitle.srcUrl" class="pdf-pane-inner" />
</div>
<div class="pdf-pane-wrap">
<pdf ref="rightPdfRef" :pdfUrl="headerTitle.transUrl" class="pdf-pane-inner" />
</div>
<div class="report-box">
<div class="pdf-pane-wrap" v-if="valueSwitch && reportUrlEnWithPage">
<pdf ref="leftPdfRef" :pdfUrl="reportUrlEnWithPage" class="pdf-pane-inner" />
</div>
<div class="pdf-pane-wrap" :class="{ 'is-full': !valueSwitch }" v-if="reportUrlWithPage">
<pdf
:key="`right-pdf-${valueSwitch ? 'split' : 'full'}`"
ref="rightPdfRef"
:pdfUrl="reportUrlWithPage"
class="pdf-pane-inner"
/>
</div>
</div>
</div>
......@@ -38,16 +96,23 @@
</template>
<script setup>
import { ref, onMounted, watch, computed } from "vue";
import { Download } from "@element-plus/icons-vue";
import { computed, ref, onMounted, watch } from "vue";
import pdf from "./pdf.vue";
import { getThinkTankReportSummary, getThinkTankReportcontentUrl } from "@/api/thinkTank/overview";
import { useRoute, useRouter } from "vue-router";
import { getSingleSanctionOverview } from "@/api/exportControlV2.0.js";
import title from "../assets/title.png";
import transIcon from "../assets/icon-translation.png";
import pdf from "./pdf.vue";
const leftPdfRef = ref(null);
const rightPdfRef = ref(null);
const reportBoxRef = ref(null);
const router = useRouter();
const route = useRoute();
const reportUrl = ref("");
const reportUrlEn = ref("");
const thinkInfo = ref({});
const defaultPdfPage = ref(1);
const sourceCurrentPage = ref(Number(route.query.currentPage) || 1);
const sourcePageSize = ref(Number(route.query.pageSize) || 12);
const opinionId = ref(route.query.opinionId || "");
const opinionContent = ref(route.query.opinionContent || "");
const headerTitle = ref({
img: title,
......@@ -56,47 +121,188 @@ const headerTitle = ref({
srcUrl: "",
transUrl: ""
});
const sanRecordId = ref("");
const isSyncing = ref(false);
// ✅ 控制中文 PDF 显示
const showChinese = ref(true);
const buildPdfPageUrl = url => {
if (!url) return "";
return `${url}#page=${defaultPdfPage.value}`;
};
// ✅ 计算当前显示模式
const showMode = computed(() => {
return showChinese.value ? "both" : "en";
});
const reportUrlWithPage = computed(() => buildPdfPageUrl(headerTitle.value.srcUrl));
const reportUrlEnWithPage = computed(() => buildPdfPageUrl(headerTitle.value.transUrl));
const valueSwitch = ref(true);
const showSearchInput = ref(true);
const searchKeywordText = ref("");
const leftPdfRef = ref(null);
const rightPdfRef = ref(null);
const matchInfo = ref({ current: 0, total: 0 });
const activePdfRef = ref(null);
const clearPdfSearchState = () => {
activePdfRef.value = null;
matchInfo.value = { current: 0, total: 0 };
const leftPdf = leftPdfRef.value;
const rightPdf = rightPdfRef.value;
if (leftPdf && typeof leftPdf.clearSearch === "function") {
leftPdf.clearSearch();
}
if (rightPdf && typeof rightPdf.clearSearch === "function") {
rightPdf.clearSearch();
}
};
// ✅ 切换中英文显示
const handleToggleChange = value => {
console.log("切换中英文显示:", value ? "中英双栏" : "仅英文");
showChinese.value = value;
const updateMatchInfo = () => {
const pdf = activePdfRef.value;
if (pdf && typeof pdf.getMatchInfo === "function") {
matchInfo.value = pdf.getMatchInfo();
return;
}
matchInfo.value = { current: 0, total: 0 };
};
// ✅ 下载功能
const handleDownload = async () => {
const files = [
{ url: headerTitle.value.srcUrl, name: "英文原版.pdf" },
{ url: headerTitle.value.transUrl, name: "中文翻译.pdf" }
];
watch(
() => searchKeywordText.value,
val => {
const keyword = String(val ?? "").trim();
if (!keyword) {
clearPdfSearchState();
}
}
);
for (const file of files) {
if (file.url) {
watch(
() => valueSwitch.value,
() => {
// 切换「显示原文」会导致 PDF 重新挂载/布局变化:清空搜索与计数,回到初始状态
searchKeywordText.value = "";
clearPdfSearchState();
}
);
const handleSearchInPdf = async () => {
const keyword = searchKeywordText.value?.trim();
if (!keyword) return;
activePdfRef.value = null;
matchInfo.value = { current: 0, total: 0 };
const leftPdf = leftPdfRef.value;
const rightPdf = rightPdfRef.value;
let page = 0;
let targetRef = null;
if (leftPdf && typeof leftPdf.searchKeyword === "function") {
page = await leftPdf.searchKeyword(keyword);
if (page) targetRef = leftPdf;
}
if (!page && rightPdf && typeof rightPdf.searchKeyword === "function") {
page = await rightPdf.searchKeyword(keyword);
if (page) targetRef = rightPdf;
}
if (page && targetRef && typeof targetRef.goToPage === "function") {
targetRef.goToPage(page);
activePdfRef.value = targetRef;
updateMatchInfo();
} else {
try {
const response = await fetch(file.url);
const { ElMessage } = await import("element-plus");
ElMessage.warning("未找到包含该关键词的页面");
} catch (_) {}
}
};
const handlePrevMatch = () => {
const pdf = activePdfRef.value;
if (!pdf || typeof pdf.prevMatch !== "function") return;
pdf.prevMatch();
updateMatchInfo();
};
const handleNextMatch = () => {
const pdf = activePdfRef.value;
if (!pdf || typeof pdf.nextMatch !== "function") return;
pdf.nextMatch();
updateMatchInfo();
};
// 下载:中英文都下载,与政令原文页相同的 fetch → blob → a 标签触发下载
const downloadOnePdf = async (url, filename) => {
const response = await fetch(url, {
method: "GET",
headers: { "Content-Type": "application/pdf" }
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = file.name;
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
window.URL.revokeObjectURL(blobUrl);
};
const handleDownload = async () => {
const urlZh = headerTitle.value.srcUrl ? String(headerTitle.value.srcUrl) : "";
const urlEn = headerTitle.value.transUrl ? String(headerTitle.value.transUrl) : "";
if (!urlZh && !urlEn) {
try {
const { ElMessage } = await import("element-plus");
ElMessage.warning("暂无下载链接");
} catch (_) {}
return;
}
const baseName = (thinkInfo.value?.name || "报告原文").replace(/[/\\?%*:|"<>]/g, "-");
const { ElMessage } = await import("element-plus");
try {
if (urlZh) {
await downloadOnePdf(urlZh, `${baseName}_中文.pdf`);
}
if (urlEn) {
if (urlZh) await new Promise(r => setTimeout(r, 300));
await downloadOnePdf(urlEn, `${baseName}_英文.pdf`);
}
if (urlZh || urlEn) {
ElMessage.success(urlZh && urlEn ? "已下载中文、英文两份 PDF" : "下载成功");
}
} catch (error) {
console.error(`下载${file.name}失败:`, error);
console.error("下载失败:", error);
ElMessage.error("PDF 下载失败,请稍后重试");
}
};
/** 接口可能返回对象或数组;字段名兼容 snake/camel */
const normalizeReportSummaryRow = row => {
if (!row || typeof row !== "object") return {};
return {
...row,
name: row.name || "",
imageUrl: row.imageUrl || row.image || "",
ename: row.ename || row.nameEn || row.englishName || "",
times: row.times || row.postDate || "",
thinkTankName: row.thinkTankName || row.thinktankName || "",
thinkTankLogoUrl: row.thinkTankLogoUrl || row.thinktankLogo || ""
};
};
const applyReportOriginalDocumentTitle = title => {
const text = String(title || "").trim();
if (!text) return;
window.sessionStorage.setItem("reportOriginalTabName", text);
document.title = text;
};
// 获取报告全局信息
const handleGetThinkTankReportSummary = async () => {
try {
const res = await getThinkTankReportSummary(router.currentRoute._value.params.id);
console.log("报告全局信息", res);
if (res.code === 200 && res.data) {
const raw = res.data;
const first = Array.isArray(raw) ? raw[0] : raw;
const normalized = normalizeReportSummaryRow(first);
thinkInfo.value = normalized;
applyReportOriginalDocumentTitle(normalized.name);
}
} catch (error) {
console.error("获取报告全局信息error", error);
}
};
......@@ -104,7 +310,6 @@ const getUrlParams = () => {
const urlParams = new URLSearchParams(window.location.search);
sanRecordId.value = urlParams.get("id") || "";
};
const getSingleSanctionOverviewData = async () => {
if (!sanRecordId.value) return;
try {
......@@ -127,195 +332,430 @@ const getSingleSanctionOverviewData = async () => {
// 更新头部信息
headerTitle.value = {
...singleSanctionOverview,
...headerTitle.value,
title: `${dateStr}${singleSanctionOverview.sanTitleZh || singleSanctionOverview.sanTitle}》`,
department: singleSanctionOverview.fileCode || "",
srcUrl: singleSanctionOverview.srcUrl || "",
transUrl: singleSanctionOverview.transUrl || ""
};
applyReportOriginalDocumentTitle(headerTitle.value.title || "");
}
} catch (error) {
console.error("获取制裁概况失败:", error);
}
};
// 同步滚动处理
const handleSyncScroll = () => {
if (isSyncing.value) return;
isSyncing.value = true;
requestAnimationFrame(() => {
isSyncing.value = false;
});
//获取原文
const handleGetThinkTankReportcontentUrl = async () => {
try {
const res = await getThinkTankReportcontentUrl(router.currentRoute._value.params.id);
console.log("获取原文", res);
if (res.code === 200 && res.data) {
reportUrl.value = res.data.content;
reportUrlEn.value = res.data.contentEn;
}
} catch (error) {
console.error("获取原文error", error);
}
};
// 监听滚动事件
const setupScrollSync = () => {
const reportBox = reportBoxRef.value;
if (!reportBox) return;
const tabActiveName = ref("报告分析");
reportBox.addEventListener("scroll", handleSyncScroll, { passive: true });
const switchTab = name => {
tabActiveName.value = name;
};
// 监听 PDF 加载完成
watch(
() => [headerTitle.value.srcUrl, headerTitle.value.transUrl],
() => {
setTimeout(() => {
setupScrollSync();
}, 1000);
},
{ deep: true }
);
onMounted(() => {
onMounted(async () => {
window.sessionStorage.setItem("curTabName", "实体清单原文");
handleGetThinkTankReportSummary();
handleGetThinkTankReportcontentUrl();
console.log("原文展示");
getUrlParams();
getSingleSanctionOverviewData();
setTimeout(() => {
setupScrollSync();
}, 500);
});
</script>
<style scoped lang="scss">
// * {
// margin: 0;
// padding: 0;
// }
.entity-list {
width: 100%;
height: 100%;
overflow-y: auto;
<style lang="scss" scoped>
.wrap {
overflow-y: hidden;
height: 100vh;
display: flex;
flex-direction: column;
.header {
width: 100%;
height: 148px;
background-color: #fff;
padding-top: 16px;
height: 88px;
box-sizing: border-box;
border-bottom: 1px solid rgba(234, 236, 238, 1);
box-shadow: 0px 0px 20px 0px rgba(25, 69, 130, 0.1);
background: rgba(255, 255, 255, 1);
position: sticky;
top: 0;
z-index: 99999;
overflow: hidden;
.header-title {
width: 1601px;
height: 72px;
background-color: rgba(246, 250, 255, 1);
.header-top {
margin: 0 auto;
border-radius: 10px;
border: 2px solid rgba(174, 214, 255, 1);
margin-top: 20px;
width: 1600px;
display: flex;
justify-content: space-between;
.header-top-left {
display: flex;
align-items: center;
margin-bottom: 12px;
position: relative;
img {
width: 54px;
width: 44px;
height: 54px;
margin-left: 15px;
margin-right: 11px;
}
.title {
margin-left: 20px;
height: 26px;
color: rgba(59, 65, 75, 1);
font-family: Microsoft YaHei;
font-size: 20px;
font-weight: 700;
font-family: "Microsoft YaHei";
line-height: 26px;
color: rgb(59, 65, 75);
letter-spacing: 0px;
text-align: left;
}
.department {
.en-title {
margin-top: 4px;
margin-left: 20px;
height: 24px;
color: rgba(95, 101, 108, 1);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
font-family: "Microsoft YaHei";
line-height: 24px;
color: rgb(95, 101, 108);
letter-spacing: 0px;
text-align: left;
/* 👇 下面是 两行文本超出省略 核心代码 */
display: -webkit-box;
-webkit-line-clamp: 1;
/* 限制显示 2 行 */
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-all;
}
.tag-box {
margin-top: 11px;
display: flex;
gap: 8px;
margin-left: 20px;
}
}
.main {
.header-top-right {
display: flex;
flex-direction: column;
text-align: right;
align-items: flex-end;
.image-name-box {
width: 200px;
height: 24px;
gap: 6px;
text-align: right;
display: flex;
justify-content: flex-end;
.name {
height: 24px;
color: rgba(95, 101, 108, 1);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 24px;
letter-spacing: 0px;
text-align: right;
}
.image {
width: 16px;
height: 16px;
margin-top: 5px;
img {
width: 100%;
height: 100%;
}
}
}
.time {
height: 24px;
margin-top: 5px;
color: rgba(95, 101, 108, 1);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 24px;
letter-spacing: 0px;
text-align: right;
}
}
}
.header-bottom {
margin: 0 auto;
margin-top: 30px;
width: 1600px;
height: 48px;
display: flex;
justify-content: space-between;
.tab-box {
width: 224px;
height: 48px;
display: flex;
gap: 24px;
.tab {
width: 94px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
cursor: pointer;
border-bottom: 2px solid transparent;
.icon {
width: 16px;
height: 16px;
img {
width: 100%;
height: 100%;
}
}
.text {
height: 24px;
color: rgba(59, 65, 75, 1);
font-family: Microsoft YaHei;
font-size: 18px;
font-weight: 400;
line-height: 24px;
}
.textActive {
color: rgba(5, 95, 194, 1);
font-weight: 700;
}
}
.tabActive {
border-bottom: 2px solid rgba(5, 95, 194, 1);
}
}
}
}
.main {
margin: 0px auto;
margin-top: 20px;
background: rgb(255, 255, 255);
width: 1601px;
height: calc(100vh - 148px);
width: 1600px;
// height: 900px;
margin-bottom: 20px;
border: 1px solid rgb(234, 236, 238);
padding-bottom: 15px;
border: 1px, solid, rgb(234, 236, 238);
box-shadow: 0 0 20px 0 rgba(25, 69, 130, 0.1);
.main-header {
height: 64px;
/* box-sizing: border-box; */
border-bottom: 1px solid rgb(234, 236, 238);
background: rgb(255, 255, 255);
margin: 0 70px;
color: rgba(59, 65, 75, 1);
font-family: "Source Han Sans CN";
font-style: Bold;
font-size: 20px;
font-weight: 700;
line-height: 26px;
letter-spacing: 0px;
width: 1456px;
text-align: left;
display: flex;
align-items: center;
justify-content: space-between;
align-items: center;
overflow: visible;
.header-right {
.btn-box {
display: flex;
align-items: center;
gap: 24px;
gap: 8px;
flex-shrink: 0;
.toggle-group {
.translate {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 10px;
height: 24px;
margin-right: 16px;
flex-shrink: 0;
span {
font-size: 14px;
color: rgb(150, 150, 150);
transition: color 0.3s;
:deep(.el-switch) {
width: 22px !important;
height: 14px !important;
margin-bottom: 5px;
margin-right: 8px;
}
&.active {
color: rgb(5, 95, 194);
font-weight: 600;
:deep(.el-switch__core) {
width: 22px !important;
height: 14px !important;
min-width: 22px !important;
}
:deep(.el-switch__button),
:deep(.el-switch__action) {
width: 10px !important;
height: 10px !important;
}
:deep(.el-switch) {
--el-switch-on-color: #055fc2;
--el-switch-off-color: #e6e7e8;
/* 打开时圆球从左边移到最右边:轨道 22px - 圆球 10px = 12px */
:deep(.el-switch.is-checked .el-switch__button),
:deep(.el-switch.is-checked .el-switch__action) {
transform: translateX(6px) !important;
}
.el-switch__label {
color: #fff;
font-size: 12px;
font-weight: 600;
.translate-image {
display: flex;
width: 16px;
height: 16px;
overflow: hidden;
img {
width: 100%;
height: 100%;
}
}
.translate-text {
font-size: 14px;
font-weight: 400;
line-height: 22px;
}
}
.btn {
width: 88px;
height: 32px;
box-sizing: border-box;
border: 1px solid rgba(230, 231, 232, 1);
border-radius: 6px;
background: rgba(255, 255, 255, 1);
display: flex;
gap: 8px;
cursor: pointer;
.icon {
width: 16px;
height: 16px;
display: inline-flex;
margin-top: 8px;
margin-left: 16px;
img {
width: 100%;
height: 100%;
}
}
.text {
margin-top: 4px;
width: 32px;
height: 24px;
color: rgb(59, 65, 75);
font-family: "Source Han Sans CN";
font-size: 16px;
font-weight: 400;
line-height: 24px;
letter-spacing: 0px;
text-align: left;
}
}
&.is-active {
color: #fff;
.search-btn {
cursor: pointer;
}
.search-input-wrap {
display: inline-flex;
align-items: center;
gap: 8px;
margin-left: 4px;
flex-shrink: 0;
}
.search-input {
width: 160px;
height: 24px;
box-sizing: border-box;
border: 1px solid rgba(231, 243, 255, 1);
background: rgba(246, 250, 255, 1);
border-radius: 4px;
padding: 0 10px;
font-family: "Source Han Sans CN";
font-size: 14px;
line-height: 22px;
outline: none;
}
.search-match-count {
font-family: "Source Han Sans CN";
font-weight: 400;
font-size: 14px;
line-height: 22px;
min-width: 48px;
text-align: center;
flex-shrink: 0;
}
// :deep(.el-button) {
// --el-button-bg-color: #055fc2;
// --el-button-border-color: #055fc2;
// --el-button-hover-bg-color: #044c9b;
// --el-button-hover-border-color: #044c9b;
.search-nav-btn {
width: 68px;
height: 24px;
box-sizing: border-box;
border: 1px solid rgba(231, 243, 255, 1);
background: rgba(246, 250, 255, 1);
border-radius: 4px;
font-family: "Source Han Sans CN";
font-weight: 400;
font-size: 14px;
line-height: 22px;
// font-size: 14px;
// padding: 10px 20px;
// }
cursor: pointer;
padding: 0;
flex-shrink: 0;
white-space: nowrap;
}
.search-nav-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
}
.report-box {
margin-left: 70px;
width: 1456px;
height: calc(100% - 64px);
height: 881px;
display: flex;
overflow-y: auto;
/* 右侧统一滚动条,控制两侧原文+译文一起滚动 */
overflow-x: hidden;
// ✅ 添加居中对齐
justify-content: center;
}
.pdf-pane-wrap {
......@@ -323,15 +763,11 @@ onMounted(() => {
max-width: 50%;
height: 100%;
min-width: 0;
transition: all 0.3s;
}
&.center-mode {
.pdf-pane-wrap.is-full {
flex: 0 0 100%;
max-width: 100%;
// ✅ 添加居中样式
width: 728px; // 约一半宽度,保持单栏时美观
margin: 0 auto;
}
}
.pdf-pane-inner {
......
<template>
<div class="pdf-viewer">
<!-- PDF 页面:canvas + textLayer 必须在同一容器内渲染 -->
<div class="page-wrap" v-for="page in pageCount" :key="page">
<canvas :ref="el => setCanvasRef(page, el)"></canvas>
<div :ref="el => setOverlayRef(page, el)" class="textLayer"></div>
</div>
<div v-if="loading" class="loading">加载中...</div>
</div>
</template>
<script>
import { ref, shallowRef, nextTick, watch } from "vue";
import * as pdfjsLib from "pdfjs-dist/legacy/build/pdf";
import PdfWorker from "pdfjs-dist/legacy/build/pdf.worker.min?worker";
// 使用 Vite 的 ?worker 直接注入 Worker,避免线上 mjs MIME 类型问题
pdfjsLib.GlobalWorkerOptions.workerPort = new PdfWorker();
export default {
name: "PdfViewer",
props: {
pdfUrl: {
type: String,
required: true
}
},
setup(props) {
const canvasMap = {};
const overlayMap = {};
const pageCount = ref(0);
const loading = ref(true);
const renderedPageCount = ref(0);
let resolveRenderAll = null;
const waitAllPagesRendered = () => {
if (pageCount.value > 0 && renderedPageCount.value >= pageCount.value) {
return Promise.resolve();
}
return new Promise(resolve => {
resolveRenderAll = resolve;
});
};
// pdfjs 的 document 对象内部使用 #private 字段,用 shallowRef 保持为原始对象引用
const pdfDocRef = shallowRef(null);
const searchKey = ref("");
const matchList = ref([]);
const matchIdx = ref(0);
const pdfjsApiRef = shallowRef(pdfjsLib);
// 保存 canvas
const setCanvasRef = (page, el) => {
if (!el) return;
canvasMap[page] = el;
};
// 保存 textLayer 容器(用于搜索高亮)
const setOverlayRef = (page, el) => {
if (!el) return;
overlayMap[page] = el;
};
// 清理 URL
const parsePdfUrl = pdfUrl => {
if (!pdfUrl || typeof pdfUrl !== "string") return "";
const [urlPart] = pdfUrl.split("#");
return urlPart;
};
// 清空所有高亮
const clearHighlights = () => {
Object.values(overlayMap).forEach(layer => {
if (!layer) return;
const rects = layer.querySelectorAll(".highlight-rect");
rects.forEach(n => n.remove());
});
};
// 重置搜索状态
const clearSearch = () => {
searchKey.value = "";
matchList.value = [];
matchIdx.value = 0;
clearHighlights();
};
// 渲染单页 PDF
const renderPage = async (pdf, pageNum) => {
const pdfPage = await pdf.getPage(pageNum);
const canvas = canvasMap[pageNum];
const textLayer = overlayMap[pageNum];
if (!canvas || !textLayer) return;
const baseViewport = pdfPage.getViewport({ scale: 1 });
const desiredWidth = canvas.clientWidth || 726;
const scale = desiredWidth / baseViewport.width;
const viewport = pdfPage.getViewport({ scale });
const context = canvas.getContext("2d");
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.style.width = `${viewport.width}px`;
canvas.style.height = `${viewport.height}px`;
textLayer.style.width = canvas.width + "px";
textLayer.style.height = canvas.height + "px";
textLayer.innerHTML = "";
textLayer.style.setProperty("--scale-factor", String(viewport.scale || 1));
await pdfPage.render({ canvasContext: context, viewport }).promise;
try {
const textContent = await pdfPage.getTextContent();
let api = pdfjsApiRef.value || pdfjsLib;
let rt = api?.renderTextLayer;
if (typeof rt !== "function") {
try {
const legacy = await import("pdfjs-dist/legacy/build/pdf");
pdfjsApiRef.value = legacy;
api = legacy;
rt = legacy?.renderTextLayer;
} catch (_) {}
}
if (typeof rt === "function") {
await rt({
textContent,
container: textLayer,
viewport,
textDivs: [],
enhanceTextSelection: false
}).promise;
}
} catch (e) {
console.warn("textLayer 渲染失败", e);
}
renderedPageCount.value += 1;
if (pageCount.value > 0 && renderedPageCount.value >= pageCount.value) {
if (typeof resolveRenderAll === "function") {
const fn = resolveRenderAll;
resolveRenderAll = null;
fn();
}
}
};
// 渲染 PDF
const renderPdf = async pdfUrl => {
const url = parsePdfUrl(pdfUrl);
if (!url) return;
loading.value = true;
pdfDocRef.value = null;
clearHighlights();
matchList.value = [];
searchKey.value = "";
renderedPageCount.value = 0;
resolveRenderAll = null;
try {
const pdf = await pdfjsLib.getDocument(url).promise;
pdfDocRef.value = pdf;
pageCount.value = pdf.numPages;
await nextTick();
for (let p = 1; p <= pdf.numPages; p++) {
await renderPage(pdf, p);
}
} catch (err) {
console.error("PDF 加载失败", err);
} finally {
loading.value = false;
}
};
// 搜索关键词 + 高亮
const doSearch = async () => {
const doc = pdfDocRef.value;
const key = searchKey.value.trim();
clearHighlights();
matchList.value = [];
matchIdx.value = 0;
if (!doc || !key) return;
await waitAllPagesRendered();
for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {
const layer = overlayMap[pageNum];
if (!layer) continue;
const nodes = Array.from(layer.querySelectorAll("span"));
for (const el of nodes) {
const t = el.textContent || "";
if (!t) continue;
let start = 0;
while (true) {
const idx = t.indexOf(key, start);
if (idx === -1) break;
matchList.value.push({ pageNum, el, startIdx: idx, endIdx: idx + key.length });
start = idx + Math.max(1, key.length);
}
}
}
if (matchList.value.length > 0) jumpTo(0);
};
// 跳转到第 N 个匹配项
const jumpTo = idx => {
if (idx < 0 || idx >= matchList.value.length) return;
matchIdx.value = idx;
const m = matchList.value[idx];
const el = m?.el;
if (!el) return;
clearHighlights();
const textNode = el.firstChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
try {
const range = document.createRange();
range.setStart(textNode, Math.max(0, m.startIdx ?? 0));
range.setEnd(textNode, Math.max(0, m.endIdx ?? 0));
const rectList = Array.from(range.getClientRects());
const pageWrap = el.closest(".page-wrap");
const layer = overlayMap[m.pageNum];
if (pageWrap && layer && rectList.length) {
const pageRect = pageWrap.getBoundingClientRect();
rectList.forEach(r => {
const mark = document.createElement("div");
mark.className = "highlight-rect";
mark.style.left = r.left - pageRect.left + "px";
mark.style.top = r.top - pageRect.top + "px";
mark.style.width = r.width + "px";
mark.style.height = r.height + "px";
layer.appendChild(mark);
});
}
range.detach?.();
} catch (e) {
// ignore
}
}
// 滚动到匹配位置
const container = el.closest(".report-box");
if (container) {
const TOP_OFFSET = 72;
const containerRect = container.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const targetTop = elRect.top - containerRect.top + container.scrollTop - TOP_OFFSET;
container.scrollTo({ top: Math.max(0, targetTop), behavior: "smooth" });
} else {
el.scrollIntoView({ behavior: "smooth", block: "center" });
}
};
const prevMatch = () => jumpTo(matchIdx.value - 1);
const nextMatch = () => jumpTo(matchIdx.value + 1);
const getMatchInfo = () => {
const total = matchList.value.length;
const current = total ? matchIdx.value + 1 : 0;
return { current, total };
};
// 外部调用方法
const searchKeyword = async keyword => {
searchKey.value = keyword;
await doSearch();
return matchList.value.length > 0 ? matchList.value[0].pageNum : 0;
};
const goToPage = pageNum => {
const canvasEl = canvasMap[pageNum];
if (!canvasEl) return;
const container = canvasEl.closest(".report-box");
if (container) {
const containerRect = container.getBoundingClientRect();
const canvasRect = canvasEl.getBoundingClientRect();
const targetTop = canvasRect.top - containerRect.top + container.scrollTop;
container.scrollTo({ top: Math.max(0, targetTop), behavior: "smooth" });
} else {
canvasEl.scrollIntoView({ behavior: "smooth", block: "start" });
}
};
// 获取容器元素(用于同步滚动)
const getContainer = () => {
const firstCanvas = canvasMap[1];
return firstCanvas ? firstCanvas.closest(".report-box") : null;
};
watch(
() => props.pdfUrl,
newVal => {
if (newVal) renderPdf(newVal);
},
{ immediate: true }
);
return {
pageCount,
setCanvasRef,
setOverlayRef,
loading,
searchKey,
doSearch,
prevMatch,
nextMatch,
getMatchInfo,
matchList,
matchIdx,
searchKeyword,
clearSearch,
goToPage,
getContainer
};
}
};
</script>
<style scoped>
.pdf-viewer {
position: relative;
width: 100%;
}
.page-wrap {
position: relative;
margin-bottom: 16px;
width: 100%;
}
canvas {
width: 100%;
height: auto;
display: block;
}
.textLayer {
position: absolute;
left: 0;
top: 0;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 2;
line-height: 1;
}
.textLayer :deep(span) {
position: absolute;
transform-origin: 0% 0%;
white-space: pre;
line-height: 1;
font-size: calc(var(--font-height, 0px) * var(--scale-factor, 1));
transform: scaleX(var(--scale-x, 1));
color: transparent;
}
.textLayer :deep(.highlight-text) {
background: #ff0;
opacity: 0.6;
padding: 0 1px;
border-radius: 2px;
}
.textLayer :deep(.highlight-rect) {
position: absolute;
background: #ff0;
opacity: 0.6;
border-radius: 2px;
pointer-events: none;
}
.loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 18px;
color: #333;
}
</style>
......@@ -17,7 +17,6 @@ import PdfWorker from "pdfjs-dist/legacy/build/pdf.worker.min?worker";
// 使用 Vite 的 ?worker 直接注入 Worker,避免线上 mjs MIME 类型问题
pdfjsLib.GlobalWorkerOptions.workerPort = new PdfWorker();
export default {
name: "PdfViewer",
props: {
......@@ -33,7 +32,6 @@ export default {
const loading = ref(true);
const renderedPageCount = ref(0);
let resolveRenderAll = null;
const waitAllPagesRendered = () => {
if (pageCount.value > 0 && renderedPageCount.value >= pageCount.value) {
return Promise.resolve();
......@@ -42,12 +40,14 @@ export default {
resolveRenderAll = resolve;
});
};
// pdfjs 的 document 对象内部使用 #private 字段,用 shallowRef 保持为原始对象引用
// pdfjs 的 document 对象内部使用 #private 字段,
// 若被 Vue 响应式深度代理会触发 "Cannot read from private field"。
// 因此用 shallowRef 保持为原始对象引用。
const pdfDocRef = shallowRef(null);
const searchKey = ref("");
const matchList = ref([]);
const matchIdx = ref(0);
// pdfjs 3.x 的 renderTextLayer 在不同入口下导出不一致,这里做一次缓存 + 兜底加载
const pdfjsApiRef = shallowRef(pdfjsLib);
// 保存 canvas
......@@ -69,7 +69,7 @@ export default {
return urlPart;
};
// 清空所有高亮
// 清空所有高亮(不销毁 textLayer)
const clearHighlights = () => {
Object.values(overlayMap).forEach(layer => {
if (!layer) return;
......@@ -78,7 +78,7 @@ export default {
});
};
// 重置搜索状态
// 重置搜索状态:清空关键词、匹配列表与高亮
const clearSearch = () => {
searchKey.value = "";
matchList.value = [];
......@@ -86,7 +86,7 @@ export default {
clearHighlights();
};
// 渲染单页 PDF
// 渲染单页 PDF(canvas + textLayer)
const renderPage = async (pdf, pageNum) => {
const pdfPage = await pdf.getPage(pageNum);
const canvas = canvasMap[pageNum];
......@@ -94,6 +94,7 @@ export default {
if (!canvas || !textLayer) return;
// 以画布的可视宽度为基准自适应缩放,避免 CSS 强行拉伸导致 textLayer/高亮错位
const baseViewport = pdfPage.getViewport({ scale: 1 });
const desiredWidth = canvas.clientWidth || 726;
const scale = desiredWidth / baseViewport.width;
......@@ -102,19 +103,23 @@ export default {
const context = canvas.getContext("2d");
canvas.width = viewport.width;
canvas.height = viewport.height;
// 保证 canvas 不再被 CSS 拉伸,和 textLayer 共享同一坐标系
canvas.style.width = `${viewport.width}px`;
canvas.style.height = `${viewport.height}px`;
textLayer.style.width = canvas.width + "px";
textLayer.style.height = canvas.height + "px";
textLayer.innerHTML = "";
// pdf.js v5 text layer 依赖 scale-factor 参与定位计算
textLayer.style.setProperty("--scale-factor", String(viewport.scale || 1));
await pdfPage.render({ canvasContext: context, viewport }).promise;
// 渲染 textLayer(pdfjs-dist 3.x):使用 renderTextLayer(不要用 TextLayer 构造器)
try {
const textContent = await pdfPage.getTextContent();
let api = pdfjsApiRef.value || pdfjsLib;
let rt = api?.renderTextLayer;
// 兜底:某些入口下 renderTextLayer 不在 pdfjsLib 上,尝试 legacy 入口
if (typeof rt !== "function") {
try {
const legacy = await import("pdfjs-dist/legacy/build/pdf");
......@@ -128,6 +133,7 @@ export default {
textContent,
container: textLayer,
viewport,
// pdfjs 3.x 需要传入 textDivs 数组
textDivs: [],
enhanceTextSelection: false
}).promise;
......@@ -175,7 +181,7 @@ export default {
}
};
// 搜索关键词 + 高亮
// 搜索关键词 + 高亮(记录每个命中的子串范围)
const doSearch = async () => {
const doc = pdfDocRef.value;
const key = searchKey.value.trim();
......@@ -184,6 +190,7 @@ export default {
matchIdx.value = 0;
if (!doc || !key) return;
// 首次搜索时确保所有页的 textLayer 已渲染完成,避免“越搜越多”
await waitAllPagesRendered();
for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {
......@@ -214,7 +221,7 @@ export default {
const el = m?.el;
if (!el) return;
clearHighlights();
// 用 Range 精确计算“子串”在页面上的矩形位置,再画黄色块,避免把整段 span 都标黄
const textNode = el.firstChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
try {
......@@ -242,7 +249,7 @@ export default {
}
}
// 滚动到匹配位置
// 优先只滚动右侧 report-box,避免触发整页滚动导致 header 遮挡
const container = el.closest(".report-box");
if (container) {
const TOP_OFFSET = 72;
......@@ -285,12 +292,6 @@ export default {
}
};
// 获取容器元素(用于同步滚动)
const getContainer = () => {
const firstCanvas = canvasMap[1];
return firstCanvas ? firstCanvas.closest(".report-box") : null;
};
watch(
() => props.pdfUrl,
newVal => {
......@@ -313,8 +314,7 @@ export default {
matchIdx,
searchKeyword,
clearSearch,
goToPage,
getContainer
goToPage
};
}
};
......@@ -349,11 +349,13 @@ canvas {
line-height: 1;
}
/* 不展示整页“文字层”,只在命中时显示黄色背景 */
.textLayer :deep(span) {
position: absolute;
transform-origin: 0% 0%;
white-space: pre;
line-height: 1;
/* pdf.js v5 TextLayer:用变量计算真实字形盒子尺寸,否则背景宽高会不准 */
font-size: calc(var(--font-height, 0px) * var(--scale-factor, 1));
transform: scaleX(var(--scale-x, 1));
color: transparent;
......
......@@ -6,7 +6,7 @@
<SearchContainer
style="margin-bottom: 0; margin-top: 48px; height: fit-content"
v-if="homeMainRef"
placeholder="搜索出口管制"
placeholder="搜索投融资限制"
:containerRef="homeMainRef"
areaName="实体清单"
/>
......
......@@ -628,6 +628,9 @@ onMounted(async () => {
.search-input {
flex: 1;
border: 1px solid #ddd;
border-radius: 4px;
height: 32px;
}
}
......
......@@ -1053,6 +1053,9 @@ onMounted(async () => {
.search-input {
width: 288px;
border: 1px solid #ddd;
border-radius: 4px;
height: 32px;
:deep(.el-input__wrapper) {
height: 32px;
......
......@@ -613,11 +613,6 @@ onMounted(() => {
</script>
<style scoped lang="scss">
// * {
// margin: 0;
// padding: 0;
// }
.sanctions-overview {
width: 1601px;
margin: 0 auto;
......@@ -960,7 +955,7 @@ onMounted(() => {
margin-bottom: 20px;
:deep(.el-input__inner) {
font-size: 16px;
font-size: 14px;
font-weight: 400;
font-family: "Microsoft YaHei";
line-height: 24px;
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论