提交 c9da112a authored 作者: 张伊明's avatar 张伊明

Merge branch 'pre' of http://8.140.26.4:10003/caijian/risk-monitor into zym-dev

# Conflicts: # package-lock.json # package.json # src/views/thinkTank/reportOriginal/pdf.vue
流水线 #117 已通过 于阶段
in 1 分 33 秒
......@@ -77,6 +77,58 @@ function extractInterpretationFromLooseText(text) {
return String(m[1]).replace(/\\n/g, "\n").trim();
}
/**
* 从流式累积 buffer 中提取「解读」字符串的已生成部分(滤掉 ```json、[、{ 等外壳,避免界面出现 json\n[\n)
* 支持未闭合的字符串(流式进行中)
* @param {string} buffer
* @returns {string}
*/
function extractStreamingInterpretationFromBuffer(buffer) {
const s = String(buffer || "");
let rest = s.replace(/^\uFEFF/, "");
const fence = rest.match(/^```(?:json)?\s*/i);
if (fence) {
rest = rest.slice(fence[0].length);
}
const keyRe =
/["'](?:解读|interpretation|analysis|content)["']\s*:\s*"/i;
const m = rest.match(keyRe);
if (!m) {
return "";
}
let pos = m.index + m[0].length;
let out = "";
while (pos < rest.length) {
const ch = rest[pos];
if (ch === '"') {
break;
}
if (ch === "\\") {
pos += 1;
if (pos >= rest.length) {
break;
}
const esc = rest[pos];
if (esc === "n") {
out += "\n";
} else if (esc === "r") {
out += "\r";
} else if (esc === "t") {
out += "\t";
} else if (esc === '"' || esc === "\\") {
out += esc;
} else {
out += esc;
}
pos += 1;
continue;
}
out += ch;
pos += 1;
}
return out;
}
/**
* 图表解读(SSE 流式)
* @param {object} data - 请求体
......@@ -94,6 +146,8 @@ export function getChartAnalysis(data, options = {}) {
return new Promise((resolve, reject) => {
let buffer = "";
let latestInterpretation = "";
/** 已推给前端的「解读」正文长度,用于只增量回调 onChunk */
let lastStreamedInterpretationLen = 0;
let settled = false;
const abortController = new AbortController();
......@@ -175,9 +229,31 @@ export function getChartAnalysis(data, options = {}) {
return;
}
// 每收到一条消息即回调,用于流式渲染
// 流式渲染:不把 ```json、[、{ 等 markdown/JSON 外壳拼到界面
if (chunk && onDelta) {
onDelta(chunk);
let parsedMsg = null;
try {
parsedMsg = JSON.parse(raw);
} catch (_) {
parsedMsg = null;
}
const isReasoningChunk =
parsedMsg &&
typeof parsedMsg === "object" &&
parsedMsg.type === "reasoning" &&
typeof parsedMsg.chunk === "string";
if (isReasoningChunk) {
onDelta(parsedMsg.chunk);
} else {
const visible = extractStreamingInterpretationFromBuffer(buffer);
if (visible.length > lastStreamedInterpretationLen) {
onDelta(
visible.slice(lastStreamedInterpretationLen)
);
lastStreamedInterpretationLen = visible.length;
}
}
}
// 如果 buffer 已经拼完 markdown code fence,则提前解析并中断连接
......
......@@ -3,9 +3,9 @@ import request from "@/api/request.js";
// 最新科技政令
export function getDepartmentList(params) {
return request({
method: 'GET',
method: 'POST',
url: `/api/administrativeDict/department`,
params
data: params
})
}
......@@ -99,15 +99,6 @@ export function getKeyOrganization(params) {
})
}
// 所有机构
export function getAllOrganization(params) {
return request({
method: 'POST',
url: `/api/administrativeOrderOverview/orderCount`,
data: params
})
}
// AI智能总结
export function getChartInterpretation(params) {
return request({
......
......@@ -33,7 +33,7 @@ export function getDecreeEntities(params) {
export function getDecreeRelatedChain(params) {
return request({
method: 'GET',
url: `/api/administrativeOrderInfo/relatedChain/${params.id}`,
url: `/api/chain/relatedChain/${params.id}`,
})
}
......@@ -41,7 +41,7 @@ export function getDecreeRelatedChain(params) {
export function getDecreeChainNodes(params) {
return request({
method: 'GET',
url: `/api/administrativeOrderInfo/relatedChainNodes/${params.id}`,
url: `/api/chain/relatedChainNodes/${params.id}`,
})
}
......@@ -49,7 +49,8 @@ export function getDecreeChainNodes(params) {
export function getDecreeRelatedEntitie(params) {
return request({
method: 'GET',
url: `/api/administrativeOrderInfo/listRelatedEntitie/${params.id}`,
url: `/api/organization/shareholding`,
params
})
}
......
......@@ -46,7 +46,10 @@ export function getThinkTankPolicyIndustryChange(params) {
params: {
startDate: params.startDate,
endDate: params.endDate
}
},
// 无数据年份(如 2026)后端可能返回 HTTP 400/500,避免走全局错误提示
validateStatus: (status) =>
(status >= 200 && status < 300) || status === 400 || status === 500
});
}
......@@ -286,8 +289,7 @@ export function getThinkPolicyIndustryChange(params) {
/**
* 获取智库政策(政策追踪列表)
* GET /api/thinkTankInfo/policy
* Query: thinkTankId, startDate, endDate, orgIds, domainIds(科技领域/智库领域,逗号分隔 id), pageNum, pageSize, sortField, sortOrder, sortFun, reportId 等
*/
export function getThinkPolicy(params) {
return request({
......
......@@ -35,7 +35,7 @@ const tipText = computed(() => props.text || `数据来源:${props.dataSource}
width: 100%;
display: flex;
gap: 8px;
justify-content: center;
justify-content: flex-start;
align-items: center;
height: 22px;
......
<template>
<div class="analysis-box-wrapper" :style="{ width: width ? width : '100%', height: height ? height : '100%' }">
<div class="wrapper-header">
<div class="header-icon"></div>
<div class="header-title">
<div v-if="title">{{ title }}</div>
<slot v-else name="custom-title"></slot>
</div>
<div class="header-btn" v-if="!showAllBtn">
<slot name="header-btn"></slot>
</div>
<div class="header-btn1" v-else>
<slot name="header-btn"></slot>
</div>
<div class="header-right">
<div class="header-right-btn" @click="handleSave" v-if="showAllBtn">
<img src="@/assets/icons/box-header-icon1.png" alt="">
</div>
<div class="header-right-btn" @click="handleDownload">
<img src="@/assets/icons/box-header-icon2.png" alt="">
</div>
<div class="header-right-btn" @click="handleCollect">
<img src="@/assets/icons/box-header-icon3.png" alt="">
</div>
</div>
</div>
<div class="wrapper-main">
<slot></slot>
</div>
</div>
<div class="analysis-box-wrapper" :style="{ width: width ? width : '100%', height: height ? height : '100%' }">
<div class="wrapper-header">
<div class="header-icon"></div>
<div class="header-title">
<div v-if="title">{{ title }}</div>
<slot v-else name="custom-title"></slot>
</div>
<div class="header-btn" v-if="!showAllBtn">
<slot name="header-btn"></slot>
</div>
<div class="header-btn1" v-else>
<slot name="header-btn"></slot>
</div>
<div class="header-right">
<div class="header-right-btn" @click="handleSave" v-if="showAllBtn">
<img src="@/assets/icons/box-header-icon1.png" alt="" />
</div>
<div class="header-right-btn" @click="handleDownload">
<img src="@/assets/icons/box-header-icon2.png" alt="" />
</div>
<div class="header-right-btn" @click="handleCollect">
<img src="@/assets/icons/box-header-icon3.png" alt="" />
</div>
</div>
</div>
<div class="wrapper-main">
<slot></slot>
</div>
</div>
</template>
<script setup>
import { ElMessage } from 'element-plus'
import { ref, computed } from 'vue'
import { ElMessage } from "element-plus";
import { ref, computed } from "vue";
const props = defineProps({
title: {
type: String,
default: ''
},
width: {
type: String,
default: ''
},
height: {
type: String,
default: ''
},
showAllBtn: {
type: Boolean,
default: true
},
// 当业务功能尚未实现时,点击右上角图标仅弹出统一提示
devTip: {
type: Boolean,
default: false
}
})
title: {
type: String,
default: ""
},
width: {
type: String,
default: ""
},
height: {
type: String,
default: ""
},
showAllBtn: {
type: Boolean,
default: true
},
// 当业务功能尚未实现时,点击右上角图标仅弹出统一提示
devTip: {
type: Boolean,
default: false
}
});
const handleSave = () => {
if (props.devTip) {
ElMessage.warning('当前功能正在开发中,敬请期待!')
return
}
ElMessage.success('保存当前内容')
// emit('save')
}
if (props.devTip) {
ElMessage.warning("当前功能正在开发中,敬请期待!");
return;
}
ElMessage.success("保存当前内容");
// emit('save')
};
const handleDownload = () => {
if (props.devTip) {
ElMessage.warning('当前功能正在开发中,敬请期待!')
return
}
ElMessage.success('下载当前内容')
// emit('download')
}
if (props.devTip) {
ElMessage.warning("当前功能正在开发中,敬请期待!");
return;
}
ElMessage.success("下载当前内容");
// emit('download')
};
const handleCollect = () => {
if (props.devTip) {
ElMessage.warning('当前功能正在开发中,敬请期待!')
return
}
ElMessage.success('收藏当前内容')
// emit('collect')
}
const emit = defineEmits(['save', 'download', 'collect'])
if (props.devTip) {
ElMessage.warning("当前功能正在开发中,敬请期待!");
return;
}
ElMessage.success("收藏当前内容");
// emit('collect')
};
const emit = defineEmits(["save", "download", "collect"]);
</script>
<style lang="scss" scoped>
.analysis-box-wrapper {
border: 1px solid rgba(234, 236, 238, 1);
border-radius: 10px;
box-shadow: 0px 0px 20px 0px rgba(25, 69, 130, 0.1);
background: rgba(255, 255, 255, 1);
.wrapper-header {
height: 45px;
display: flex;
padding-right: 14px;
align-items: center;
box-sizing: border-box;
.header-icon {
width: 8px;
height: 20px;
background: var(--color-main-active);
border-radius: 0 4px 4px 0;
margin-right: 14px;
}
.header-title {
flex: auto;
width: 20px;
// color: var(--color-main-active);
// font-family: Source Han Sans CN;
// font-size: 20px;
// font-weight: 700;
// line-height: 26px;
// letter-spacing: 0px;
height: 100%;
&>div {
height: 100%;
color: var(--color-main-active);
font-family: Microsoft YaHei;
font-size: 20px;
line-height: 45px;
font-weight: 700;
}
}
.header-btn {
// display: flex;
// justify-content: flex-end;
// gap: 8px;
margin-right: 10px;
}
// .header-btn1 {
// position: absolute;
// top: 14px;
// right: 116px;
// }
.header-right {
height: 28px;
display: flex;
justify-content: flex-end;
gap: 4px;
.header-right-btn {
width: 28px;
height: 28px;
cursor: pointer;
img {
width: 100%;
height: 100%;
}
}
}
}
.wrapper-main {
height: calc(100% - 45px);
overflow: hidden;
// overflow-y: auto;
padding: 5px auto;
}
border: 1px solid rgba(234, 236, 238, 1);
border-radius: 10px;
box-shadow: 0px 0px 20px 0px rgba(25, 69, 130, 0.1);
background: rgba(255, 255, 255, 1);
position: relative;
.wrapper-header {
height: 45px;
display: flex;
padding-right: 14px;
align-items: center;
box-sizing: border-box;
.header-icon {
width: 8px;
height: 20px;
background: var(--color-main-active);
border-radius: 0 4px 4px 0;
margin-right: 14px;
}
.header-title {
flex: auto;
width: 20px;
// color: var(--color-main-active);
// font-family: Source Han Sans CN;
// font-size: 20px;
// font-weight: 700;
// line-height: 26px;
// letter-spacing: 0px;
height: 100%;
& > div {
height: 100%;
color: var(--color-main-active);
font-family: Microsoft YaHei;
font-size: 20px;
line-height: 45px;
font-weight: 700;
}
}
.header-btn {
// display: flex;
// justify-content: flex-end;
// gap: 8px;
margin-right: 10px;
}
// .header-btn1 {
// position: absolute;
// top: 14px;
// right: 116px;
// }
.header-right {
height: 28px;
display: flex;
justify-content: flex-end;
gap: 4px;
.header-right-btn {
width: 28px;
height: 28px;
cursor: pointer;
img {
width: 100%;
height: 100%;
}
}
}
}
.wrapper-main {
height: calc(100% - 45px);
overflow: hidden;
// overflow-y: auto;
padding: 5px auto;
}
}
</style>
......@@ -30,12 +30,12 @@
<div class="organization-list" ref="refOrganization" v-loading="organizationInfo.loading">
<div class="organization-item" v-for="(item, index) in organizationInfo.list" :key="index" @click="handleToInstitution(item)">
<div class="item-left">
<img :src="item.imgUrl || DefaultIcon2" alt="" />
<img :src="item.orgImage || DefaultIcon2" alt="" />
</div>
<div class="item-right one-line-ellipsis">{{ item.orgName }}</div>
<div class="item-total">{{ item.totalOrderNum }}项</div>
<div class="item-total">{{ item.total }}项</div>
<el-icon color="var(--color-primary-100)"><ArrowRightBold /></el-icon>
<div class="item-dot" v-if="item.recentOrderNum">+{{item.recentOrderNum}}</div>
<div class="item-dot" v-if="item.totalRecent">+{{item.totalRecent}}</div>
</div>
</div>
<div class="pagination-box">
......@@ -56,7 +56,7 @@ import { Search } from '@element-plus/icons-vue'
import router from "@/router";
import TimeTabPane from '@/components/base/TimeTabPane/index.vue';
import { getAllOrganization } from "@/api/decree/home";
import { getDepartmentList } from "@/api/decree/home";
import tipsTcon from "./assets/icons/tips-icon.png";
import DefaultIcon2 from "@/assets/icons/default-icon2.png";
......@@ -77,7 +77,7 @@ const onAllOrganization = async (num) => {
organizationInfo.loading = true
try {
let {keyWord, pageNum, pageSize, day} = organizationInfo
const res = await getAllOrganization({day, pageNum:pageNum-1, pageSize, keyWord: keyWord||undefined});
const res = await getDepartmentList({day, pageNum:pageNum-1, pageSize, keyWord: keyWord||undefined});
console.log("机构列表", res);
if (res.code === 200) {
organizationInfo.list = res.data.orgList || [];
......
......@@ -47,7 +47,7 @@
<div class="item-dot" v-if="item.recentOrderNum">+{{item.recentOrderNum}}</div>
</div>
<div class="organization-item" @click="onNavigateTo()">
<div class="item-more">查看全部机构 ({{govInsList.length+1}}家)</div>
<div class="item-more">查看全部机构 ({{govInsList.length}}家)</div>
<el-icon color="var(--color-primary-100)"><ArrowRightBold /></el-icon>
</div>
</div>
......@@ -233,7 +233,7 @@
<div class="data-origin-icon">
<img :src="tipsTcon" alt="">
</div>
<div class="data-origin-text">科技政领域分布情况,数据来源:美国各行政机构官网</div>
<div class="data-origin-text">科技政领域分布情况,数据来源:美国各行政机构官网</div>
</div>
<div class="ai-pane">
<AiButton />
......@@ -287,7 +287,7 @@
<div class="data-origin-icon">
<img :src="tipsTcon" alt="">
</div>
<div class="data-origin-text">关键科技政列表,数据来源:美国各行政机构官网</div>
<div class="data-origin-text">关键科技政列表,数据来源:美国各行政机构官网</div>
</div>
</div>
<div class="box8">
......@@ -321,7 +321,7 @@
<div class="data-origin-icon">
<img :src="tipsTcon" alt="">
</div>
<div class="data-origin-text">科技政重点条款词云,数据来源:美国各行政机构官网</div>
<div class="data-origin-text">科技政重点条款词云,数据来源:美国各行政机构官网</div>
</div>
</div>
</div>
......@@ -502,10 +502,10 @@ const govInsList = ref([]);
const checkedGovIns = ref([]);
const handleGetDepartmentList = async () => {
try {
const res = await getDepartmentList({day:7});
const res = await getDepartmentList({});
console.log("机构列表", res);
if (res.code === 200 && res.data) {
govInsList.value = res.data;
if (res.code === 200) {
govInsList.value = res.data.orgList;
}
} catch (error) {
console.error("获取机构列表error", error);
......
......@@ -100,7 +100,7 @@ import {
getDecreeRelatedEntitie
} from "@/api/decree/influence";
import ChartChain from "./com/ChartChain.vue";
import AiTips from "./com/AiTips.vue";
// import AiTips from "./com/AiTips.vue";
import GraphChart from "@/components/base/GraphChart/index.vue";
import defaultIcon2 from "@/assets/icons/default-icon2.png";
import noticeIcon from "./assets/images/notice-icon.png";
......@@ -186,7 +186,7 @@ const headerChartData = (row) => {
onDecreeRelatedChain(row.id)
break;
case 2:
onDecreeRelatedEntitie(row.id)
onDecreeRelatedEntitie(row.orgId)
break;
}
}
......@@ -247,14 +247,17 @@ const graphInfo = reactive({
nodes: [],
links: [],
});
const onDecreeRelatedEntitie = async (id) => {
const onDecreeRelatedEntitie = async (orgId) => {
try {
const res = await getDecreeRelatedEntitie({ id });
const res = await getDecreeRelatedEntitie({ orgId, rule:false, withSanInfo:false });
console.log("实体关系:", res);
if (res.code === 200) {
graphInfo.links = res.data.map(onFormatLink)
graphInfo.nodes = res.data.map(onFormatNode)
graphInfo.nodes.unshift(onFormatNode(entityInfo.node))
let arr1 = res.data.parentOrgList.map(item => ({ ...item, level: 1 }))
let arr3 = res.data.childrenOrgList.map(item => ({ ...item, level: 3 }))
graphInfo.links = [...arr1,...arr3].map(onFormatLink)
graphInfo.nodes = [...arr1,...arr3].map(onFormatNode)
graphInfo.nodes.unshift(onFormatNode({name:res.data.orgName, id:res.data.orgId}, -1))
}
} catch (error) {
console.log("获取实体关系失败", error);
......@@ -262,17 +265,18 @@ const onDecreeRelatedEntitie = async (id) => {
}
const onFormatLink = (item, index) => {
return {
id: `link-${index+1}`,
source: item.id+'', target: entityInfo.id+'',
label: { show: true, color: "#055fc2", backgroundColor: "#eef7ff", borderWidth: 0, offset: [0, 15], formatter: item.relation },
id: `link-${index}-${item.id}`,
target: item.level==3 ? `${index}-${item.id}` : `-1-${entityInfo.node.orgId}`,
source: item.level==3 ? `-1-${entityInfo.node.orgId}` : `${index}-${item.id}`,
label: { show: true, color: "#055fc2", backgroundColor: "#eef7ff", borderWidth: 0, offset: [0, 15], formatter: item.description },
lineStyle: { color: '#B9DCFF', type: "solid", opacity: 1 }
}
}
const onFormatNode = (item) => {
let leader = item.id == entityInfo.id;
const onFormatNode = (item, index) => {
let leader = item.id == entityInfo.node.orgId;
return {
id: item.id+'',
name: onWordWrap(item.companyName, 7),
id: `${index}-${item.id}`,
name: onWordWrap(item.name, 7),
label: {
show: true,
color: "#3b414b",
......
......@@ -196,10 +196,10 @@ const setActivePanelId = id => {
background-image: url("../../assets/images/activeDown.png");
}
.iconUp {
background-image: url("../../assets/images/UnActiveUp.png");
background-image: url("../../assets/images/unActiveUp.png");
}
.iconDown {
background-image: url("../../assets/images/UnActiveDown.png");
background-image: url("../../assets/images/unActiveDown.png");
}
}
.activeItem {
......
......@@ -368,19 +368,29 @@
</el-col>
</el-row>
<el-row :gutter="20" style="width: 1600px; margin: 0 auto; height: 505px; margin-top: 16px">
<el-row :gutter="20" style="width: 1600px; margin: 0 auto; height: 540px; margin-top: 16px">
<el-col :span="8">
<custom-container title="实体领域分布" :titleIcon="radarIcon" height="480px">
<custom-container title="实体领域分布" :titleIcon="radarIcon" height="540px">
<template #header-right>
<el-checkbox v-model="domainChecked" label="50%规则" size="large" />
</template>
<template #default>
<EChart :option="radarOption" autoresize :style="{ height: '460px' }" />
<EChart :option="radarOption" autoresize :style="{ height: '420px' }" />
<div class="data-origin-box">
<div class="data-origin-icon">
<img :src="tipsIcon" alt="" />
</div>
<div class="data-origin-text">实体领域分布情况,数据来源:美国各行政机构官网</div>
</div>
<div class="ai-pane">
<AiButton />
<AiPane :aiContent="radarChart.loading ? '解读生成中...' : radarChart.interpretation" />
</div>
</template>
</custom-container>
</el-col>
<el-col :span="16">
<custom-container title="实体清单数量增长趋势" :titleIcon="qushiIcon" height="480px">
<custom-container title="实体清单数量增长趋势" :titleIcon="qushiIcon" height="540px">
<template #header-right>
<div style="display: flex; align-items: center; gap: 16px">
<el-checkbox v-model="trendChecked" label="50%规则" size="large" />
......@@ -390,13 +400,23 @@
</div>
</template>
<template #default>
<EChart :option="trendOption" autoresize :style="{ height: '400px' }" />
<EChart :option="trendOption" autoresize :style="{ height: '420px' }" />
<div class="data-origin-box">
<div class="data-origin-icon">
<img :src="tipsIcon" alt="" />
</div>
<div class="data-origin-text">实体清单数量增长趋势情况,数据来源:美国各行政机构官网</div>
</div>
<div class="ai-pane">
<AiButton />
<AiPane :aiContent="trendChart.loading ? '解读生成中...' : trendChart.interpretation" />
</div>
</template>
</custom-container>
</el-col>
</el-row>
<el-row :gutter="20" style="width: 1600px; margin: 0 auto; margin-top: 39px">
<el-row :gutter="20" style="width: 1600px; margin: 0 auto; margin-top: 39px; padding-bottom: 60px">
<CustomTitle id="position4" title="资源库" style="margin-top: 0px" />
<div class="resource-tabs">
<div
......@@ -714,11 +734,18 @@ import { ElMessage, ElMessageBox } from "element-plus";
import { DArrowRight, Warning, Search } from "@element-plus/icons-vue";
import EChart from "@/components/Chart/index.vue";
import tipsIcon from "./assets/icons/info-icon.png";
import AiButton from "@/components/base/Ai/AiButton/index.vue";
import AiPane from "@/components/base/Ai/AiPane/index.vue";
import { useChartInterpretation } from "@/views/exportControl/utils/common";
const sanctionCountChart = useChartInterpretation();
import { TAGTYPE } from "@/public/constant";
import { useGotoCompanyPages } from "@/router/modules/company";
import { useGotoNewsDetail } from "@/router/modules/news";
const gotoCompanyPages = useGotoCompanyPages();
const gotoNewsDetail = useGotoNewsDetail();
const trendChart = useChartInterpretation();
const radarChart = useChartInterpretation();
import { useRouter } from "vue-router";
......@@ -1026,6 +1053,7 @@ const fetchTrendData = async () => {
});
if (res && res[0] && res[0].yearDomainCount) {
trendOption.value = processYearDomainCountData(res[0].yearDomainCount);
trendChart.interpret({ type: "柱状图", name: "实体清单数量增长趋势", data: res[0].yearDomainCount });
}
} catch (error) {
console.error("获取趋势图数据失败:", error);
......@@ -1297,6 +1325,7 @@ const fetchRadarData = async checked => {
}
};
});
radarChart.interpret({ type: "雷达图", name: "实体领域分布", data: data });
}
} catch (error) {
console.error("获取雷达图数据失败:", error);
......@@ -3573,7 +3602,7 @@ const handleMediaClick = item => {
}
.scroll-main {
height: calc(100% - 144px) !important;
// height: calc(100% - 144px) !important;
}
.center-center {
......@@ -3596,4 +3625,50 @@ const handleMediaClick = item => {
background: rgba(255, 255, 255, 1);
}
}
.data-origin-box {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 22px 0;
.data-origin-icon {
width: 16px;
height: 16px;
font-size: 0px;
margin-right: 8px;
img {
width: 100%;
height: 100%;
}
}
.data-origin-text {
font-family: Source Han Sans CN;
font-size: 14px;
color: var(--text-primary-50-color);
}
}
.ai-pane {
position: absolute;
right: 0px;
bottom: 15px;
z-index: 2;
:deep(.ai-pane-wrapper) {
display: none;
}
:deep(.ai-button-wrapper) {
display: flex;
}
&:hover {
width: 100%;
bottom: 0px;
:deep(.ai-pane-wrapper) {
display: block;
}
:deep(.ai-button-wrapper) {
display: none;
}
}
}
</style>
import { ref } from "vue";
export const useChartInterpretation = () => {
const loading = ref(false);
const interpretation = ref("");
const error = ref(null);
const interpret = async text => {
loading.value = true;
error.value = null;
interpretation.value = "";
try {
const response = await fetch("/aiAnalysis/chart_interpretation", {
method: "POST",
headers: {
"X-API-Key": "aircasKEY19491001",
"Content-Type": "application/json"
},
body: JSON.stringify({ text })
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const content = line.substring(6);
const textMatch = content.match(/"解读":\s*"([^"]*)"/);
if (textMatch && textMatch[1]) {
interpretation.value = textMatch[1];
}
}
}
}
} catch (err) {
error.value = err.message || "AI 解读失败";
console.error("AI Chart Interpretation Error:", err);
} finally {
loading.value = false;
}
};
return {
loading,
interpretation,
error,
interpret
};
};
......@@ -104,8 +104,9 @@
</div>
</div>
</template>
<div class="echarts" ref="sanctionCountChartRef"></div>
<div class="bottom">
<!-- <div class="echarts" ref="sanctionCountChartRef"></div> -->
<EChart :option="sanctionCountChartOption" autoresize :style="{ height: '300px' }" />
<!-- <div class="bottom">
<div class="ai">
<div class="left">
<img :src="ai" alt="" class="icon1" />
......@@ -115,56 +116,20 @@
<img :src="right" alt="" class="icon2" />
</div>
</div>
</div> -->
<div class="data-origin-box">
<div class="data-origin-icon">
<img :src="tipsIcon" alt="" />
</div>
<div class="data-origin-text">制裁实体数量变化情况,数据来源:美国各行政机构官网</div>
</div>
<div class="ai-pane">
<AiButton />
<AiPane :aiContent="sanctionCountChart.interpretation" />
</div>
</AnalysisBox>
</div>
<div class="main-item">
<!-- <div class="title-com">
<div class="box"></div>
<div class="text">制裁实体地域分布情况</div>
<div class="right-group">
<el-select
v-model="regionTime"
class="time-select"
placeholder="请选择"
@change="getRegionCountData"
>
<el-option v-for="item in timeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<div class="btn">
<img src="../../assets/数据库按钮.png" alt="" />
<img src="../../assets/下载按钮.png" alt="" />
<img src="../../assets/收藏按钮.png" alt="" />
</div>
</div>
</div>
<div class="map-wrapper">
<div class="map-chart" ref="mapChartRef"></div>
<div class="rank-list">
<div class="rank-item" v-for="(item, index) in rankData" :key="index">
<div class="rank-index" :class="'rank-' + (index + 1)">{{ index + 1 }}</div>
<div class="rank-name">{{ item.name }}</div>
<div class="rank-bar-bg">
<div
class="rank-bar-fill"
:style="{ width: (item.value / maxRankValue) * 100 + '%', background: getBarColor(index) }"
></div>
</div>
<div class="rank-value">{{ item.value }}家</div>
</div>
</div>
</div>
<div class="bottom">
<div class="ai">
<div class="left">
<img :src="ai" alt="" class="icon1" />
<div class="text">我国被制裁实体多分布于沿海经济活跃省份。</div>
</div>
<div class="right">
<img :src="right" alt="" class="icon2" />
</div>
</div>
</div> -->
<AnalysisBox title="制裁实体地域分布情况">
<template #header-btn>
<el-select v-model="regionTime" class="time-select" placeholder="请选择" @change="getRegionCountData">
......@@ -190,7 +155,7 @@
</div>
</div>
</div>
<div class="bottom">
<!-- <div class="bottom">
<div class="ai">
<div class="left">
<img :src="ai" alt="" class="icon1" />
......@@ -200,44 +165,29 @@
<img :src="right" alt="" class="icon2" />
</div>
</div>
</div> -->
<div class="data-origin-box">
<div class="data-origin-icon">
<img :src="tipsIcon" alt="" />
</div>
<div class="data-origin-text">制裁实体地域分布情况,数据来源:美国各行政机构官网</div>
</div>
<div class="ai-pane">
<AiButton />
<AiPane :aiContent="rankChart.interpretation" />
</div>
</AnalysisBox>
</div>
<div class="main-item">
<!-- <div class="title-com">
<div class="box"></div>
<div class="text">制裁实体领域分布情况</div>
<div class="right-group">
<el-select v-model="domainTime" class="time-select" placeholder="请选择" @change="getDomainCountData">
<el-option v-for="item in timeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<div class="btn">
<img src="../../assets/数据库按钮.png" alt="" />
<img src="../../assets/下载按钮.png" alt="" />
<img src="../../assets/收藏按钮.png" alt="" />
</div>
</div>
</div>
<div class="echarts" ref="domainChartRef"></div>
<div class="bottom">
<div class="ai">
<div class="left">
<img :src="ai" alt="" class="icon1" />
<div class="text">美国对中国的制裁集中在半导体、人工智能等领域。</div>
</div>
<div class="right">
<img :src="right" alt="" class="icon2" />
</div>
</div>
</div> -->
<AnalysisBox title="制裁实体领域分布情况">
<template #header-btn>
<el-select v-model="domainTime" class="time-select" placeholder="请选择" @change="getDomainCountData">
<el-option v-for="item in timeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</template>
<div class="echarts" ref="domainChartRef"></div>
<div class="bottom">
<!-- <div class="echarts" ref="domainChartRef"></div> -->
<EChart :option="domainChartOption" autoresize :style="{ height: '300px' }" />
<!-- <div class="bottom">
<div class="ai">
<div class="left">
<img :src="ai" alt="" class="icon1" />
......@@ -247,44 +197,29 @@
<img :src="right" alt="" class="icon2" />
</div>
</div>
</div> -->
<div class="data-origin-box">
<div class="data-origin-icon">
<img :src="tipsIcon" alt="" />
</div>
<div class="data-origin-text">制裁实体领域分布情况,数据来源:美国各行政机构官网</div>
</div>
<div class="ai-pane">
<AiButton />
<AiPane :aiContent="domainChart.interpretation" />
</div>
</AnalysisBox>
</div>
<div class="main-item">
<!-- <div class="title-com">
<div class="box"></div>
<div class="text">制裁实体类型分布情况</div>
<div class="right-group">
<el-select v-model="typeTime" class="time-select" placeholder="请选择" @change="getTypeCountData">
<el-option v-for="item in timeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<div class="btn">
<img src="../../assets/数据库按钮.png" alt="" />
<img src="../../assets/下载按钮.png" alt="" />
<img src="../../assets/收藏按钮.png" alt="" />
</div>
</div>
</div>
<div class="echarts" ref="typeChartRef"></div>
<div class="bottom">
<div class="ai">
<div class="left">
<img :src="ai" alt="" class="icon1" />
<div class="text">我国被制裁实体以企业、科研院所和高校为主。</div>
</div>
<div class="right">
<img :src="right" alt="" class="icon2" />
</div>
</div>
</div> -->
<AnalysisBox title="制裁实体类型分布情况">
<template #header-btn>
<el-select v-model="typeTime" class="time-select" placeholder="请选择" @change="getTypeCountData">
<el-option v-for="item in timeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</template>
<div class="echarts" ref="typeChartRef"></div>
<div class="bottom">
<!-- <div class="echarts" ref="typeChartRef"></div> -->
<EChart :option="typeChartOption" autoresize :style="{ height: '300px' }" />
<!-- <div class="bottom">
<div class="ai">
<div class="left">
<img :src="ai" alt="" class="icon1" />
......@@ -294,6 +229,16 @@
<img :src="right" alt="" class="icon2" />
</div>
</div>
</div> -->
<div class="data-origin-box">
<div class="data-origin-icon">
<img :src="tipsIcon" alt="" />
</div>
<div class="data-origin-text">制裁实体类型分布情况,数据来源:美国各行政机构官网</div>
</div>
<div class="ai-pane">
<AiButton />
<AiPane :aiContent="typeChart.interpretation" />
</div>
</AnalysisBox>
</div>
......@@ -314,7 +259,16 @@ import {
getTechDomainCount,
getEntityTypeCount
} from "@/api/exportControlV2.0";
import EChart from "@/components/Chart/index.vue";
import { useRoute } from "vue-router";
import tipsIcon from "../../../assets/icons/info-icon.png";
import AiButton from "@/components/base/Ai/AiButton/index.vue";
import AiPane from "@/components/base/Ai/AiPane/index.vue";
import { useChartInterpretation } from "@/views/exportControl/utils/common";
const sanctionCountChart = useChartInterpretation();
const domainChart = useChartInterpretation();
const typeChart = useChartInterpretation();
const rankChart = useChartInterpretation();
const route = useRoute();
// 实体清单-数据统计-制裁实体类型分布情况
......@@ -337,6 +291,7 @@ const getTypeCountData = async () => {
name: item.name,
value: item.count || item.value
}));
typeChart.interpret({ type: "饼图", name: "制裁实体类型分布情况", data: data });
updateTypeChart();
}
} catch (error) {
......@@ -365,6 +320,7 @@ const getDomainCountData = async () => {
value: item.count || item.value
}));
updateDomainChart();
domainChart.interpret({ type: "饼图", name: "制裁实体领域分布情况", data: data });
}
} catch (error) {
console.error("获取实体清单-数据统计-制裁实体领域分布情况失败:", error);
......@@ -401,6 +357,7 @@ const getRegionCountData = async () => {
}));
// Sort by value descending
rankData.value.sort((a, b) => b.value - a.value);
rankChart.interpret({ type: "柱状图", name: "制裁实体地域分布情况", data: data });
updateMapChart();
}
} catch (error) {
......@@ -421,6 +378,7 @@ const getSanctionCountChangeData = async () => {
const res = await getSanctionCountChange(param);
sanctionCountChange.value = res.data || [];
updateSanctionCountChart();
sanctionCountChart.interpret({ type: "饼图", name: "制裁实体数量变化情况", data: res.data });
} catch (error) {
console.error("获取实体清单-数据统计-制裁实体数量变化情况失败:", error);
}
......@@ -467,13 +425,73 @@ const getBarColor = index => {
if (index === 1 || index === 2) return "linear-gradient(90deg, rgba(255, 172, 77, 0) 0%, rgba(255, 172, 77, 1) 100%)";
return "linear-gradient(90deg, rgba(5, 95, 194, 0) 0%, rgba(5, 95, 194, 1) 100%)";
};
const sanctionCountChartOption = ref({
grid: {
top: "15%",
left: "3%",
right: "4%",
bottom: "3%",
containLabel: true
},
tooltip: {
trigger: "axis",
axisPointer: {
type: "shadow"
}
},
xAxis: {
type: "category",
data: [],
axisTick: { show: false },
axisLine: { show: false },
axisLabel: {
color: "#909399",
fontFamily: "Microsoft YaHei",
interval: 0,
rotate: 0 // 如果数据较多,旋转标签
}
},
yAxis: {
type: "value",
name: "数量",
nameLocation: "end",
nameGap: 5,
nameTextStyle: {
padding: [0, 40, 10, 0] // [上, 右, 下, 左] —— 减少右侧 padding 相当于左移
},
splitLine: {
lineStyle: {
type: "dashed",
color: "#E6EBF5"
}
},
axisLabel: {
color: "#909399",
fontFamily: "Microsoft YaHei"
}
},
series: [
{
data: [],
type: "bar",
barWidth: 16,
itemStyle: {
borderRadius: [20, 20, 0, 0],
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "rgb(5, 95, 194)" },
{ offset: 1, color: "rgba(5, 95, 194, 0)" }
])
}
}
]
});
const updateSanctionCountChart = () => {
if (!sanctionCountChartRef.value) return;
let chart = echarts.getInstanceByDom(sanctionCountChartRef.value);
if (!chart) {
chart = echarts.init(sanctionCountChartRef.value);
}
// if (!sanctionCountChartRef.value) return;
// let chart = echarts.getInstanceByDom(sanctionCountChartRef.value);
// if (!chart) {
// chart = echarts.init(sanctionCountChartRef.value);
// }
const data = sanctionCountChange.value || [];
// 按日期/年份排序 (从小到大)
......@@ -488,65 +506,68 @@ const updateSanctionCountChart = () => {
const xData = data.map(item => item.year || item.name || item.date || "");
const yData = data.map(item => item.count || item.value || item.num || 0);
const option = {
grid: {
top: "15%",
left: "3%",
right: "4%",
bottom: "3%",
containLabel: true
},
tooltip: {
trigger: "axis",
axisPointer: {
type: "shadow"
}
},
xAxis: {
type: "category",
data: xData.length ? xData : ["2016", "2017", "2018", "2019", "2020", "2021", "2022", "2023", "2024", "2025"],
axisTick: { show: false },
axisLine: { show: false },
axisLabel: {
color: "#909399",
fontFamily: "Microsoft YaHei",
interval: 0,
rotate: xData.length > 10 ? 30 : 0 // 如果数据较多,旋转标签
}
},
yAxis: {
type: "value",
splitLine: {
lineStyle: {
type: "dashed",
color: "#E6EBF5"
}
},
axisLabel: {
color: "#909399",
fontFamily: "Microsoft YaHei"
}
},
series: [
{
data: yData.length ? yData : [200, 250, 300, 400, 500, 600, 610, 650, 750, 850],
type: "bar",
barWidth: 16,
itemStyle: {
borderRadius: [20, 20, 0, 0],
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "rgb(5, 95, 194)" },
{ offset: 1, color: "rgba(5, 95, 194, 0)" }
])
}
}
]
};
chart.setOption(option);
window.addEventListener("resize", () => {
chart.resize();
});
sanctionCountChartOption.value.xAxis.data = xData;
sanctionCountChartOption.value.series[0].data = yData;
sanctionCountChartOption.value.xAxis.axisLabel.rotate = xData.length > 10 ? 30 : 0; // 如果数据较多,旋转标签
// const option = {
// grid: {
// top: "15%",
// left: "3%",
// right: "4%",
// bottom: "3%",
// containLabel: true
// },
// tooltip: {
// trigger: "axis",
// axisPointer: {
// type: "shadow"
// }
// },
// xAxis: {
// type: "category",
// data: xData.length ? xData : ["2016", "2017", "2018", "2019", "2020", "2021", "2022", "2023", "2024", "2025"],
// axisTick: { show: false },
// axisLine: { show: false },
// axisLabel: {
// color: "#909399",
// fontFamily: "Microsoft YaHei",
// interval: 0,
// rotate: xData.length > 10 ? 30 : 0 // 如果数据较多,旋转标签
// }
// },
// yAxis: {
// type: "value",
// splitLine: {
// lineStyle: {
// type: "dashed",
// color: "#E6EBF5"
// }
// },
// axisLabel: {
// color: "#909399",
// fontFamily: "Microsoft YaHei"
// }
// },
// series: [
// {
// data: yData.length ? yData : [200, 250, 300, 400, 500, 600, 610, 650, 750, 850],
// type: "bar",
// barWidth: 16,
// itemStyle: {
// borderRadius: [20, 20, 0, 0],
// color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
// { offset: 0, color: "rgb(5, 95, 194)" },
// { offset: 1, color: "rgba(5, 95, 194, 0)" }
// ])
// }
// }
// ]
// };
// chart.setOption(option);
// window.addEventListener("resize", () => {
// chart.resize();
// });
};
const initSanctionCountChart = () => {
......@@ -622,12 +643,95 @@ const initMapChart = () => {
updateMapChart();
};
const domainChartOption = ref({
tooltip: {
trigger: "item",
formatter: params => {
// 如果是“其他”项,展示详细列表
if (params.name === "其他" && params.data.extra) {
let listStr = params.data.extra
.map(item => {
const percent = totalValue ? ((item.value / totalValue) * 100).toFixed(2) : 0;
return `<div style="display:flex;justify-content:space-between;gap:10px;"><span>${item.name}</span><span style="font-weight:bold">${item.value}家 (${percent}%)</span></div>`;
})
.join("");
return `<div style="text-align:left;">
<div style="font-weight:bold;margin-bottom:5px;">其他 (${params.value}${params.percent}%)</div>
${listStr}
</div>`;
}
// 默认展示
return `${params.name}: ${params.value} (${params.percent}%)`;
}
},
color: [
"#60acfc", // 集成电路 - 蓝色
"#feb64d", // 人工智能 - 橙色
"#5bc49f", // 通信网络 - 青色
"#959cf3", // 量子科技 - 淡蓝紫
"#ff7c7c", // 先进制造 - 红色
"#b689ea", // 新材料 - 紫色
"#32d3eb" // 航空航天 - 浅蓝
],
series: [
{
name: "制裁实体领域分布",
type: "pie",
radius: [73.5, 89.5],
center: ["50%", "50%"],
startAngle: 0,
data: [],
label: {
show: true,
alignTo: "edge",
minMargin: 5,
edgeDistance: 10,
formatter: params => {
return "{name|" + params.name + "}\n{value|" + params.value + "家 " + params.percent + "%}";
},
rich: {
name: {
fontSize: 18,
fontWeight: 700,
color: "rgb(59, 65, 75)",
padding: [0, 0, 5, 0],
fontFamily: "Microsoft YaHei",
lineHeight: 26
},
value: {
fontSize: 16,
fontWeight: 400,
color: "rgb(95, 101, 108)",
fontFamily: "Microsoft YaHei",
lineHeight: 24,
padding: [5, 0, 0, 0]
}
}
},
labelLine: {
show: true,
length: 15,
length2: 0,
maxSurfaceAngle: 80,
lineStyle: {
width: 1.1
}
},
labelLayout: {
hideOverlap: true
},
itemStyle: {
borderWidth: 0
}
}
]
});
const updateDomainChart = () => {
if (!domainChartRef.value) return;
let chart = echarts.getInstanceByDom(domainChartRef.value);
if (!chart) {
chart = echarts.init(domainChartRef.value);
}
// if (!domainChartRef.value) return;
// let chart = echarts.getInstanceByDom(domainChartRef.value);
// if (!chart) {
// chart = echarts.init(domainChartRef.value);
// }
let data = domainData.value.length ? [...domainData.value] : [];
......@@ -656,108 +760,192 @@ const updateDomainChart = () => {
}
data = topData;
}
domainChartOption.value.series[0].data = data;
// const option = {
// tooltip: {
// trigger: "item",
// formatter: params => {
// // 如果是“其他”项,展示详细列表
// if (params.name === "其他" && params.data.extra) {
// let listStr = params.data.extra
// .map(item => {
// const percent = totalValue ? ((item.value / totalValue) * 100).toFixed(2) : 0;
// return `<div style="display:flex;justify-content:space-between;gap:10px;"><span>${item.name}</span><span style="font-weight:bold">${item.value}家 (${percent}%)</span></div>`;
// })
// .join("");
// return `<div style="text-align:left;">
// <div style="font-weight:bold;margin-bottom:5px;">其他 (${params.value} ${params.percent}%)</div>
// ${listStr}
// </div>`;
// }
// // 默认展示
// return `${params.name}: ${params.value} (${params.percent}%)`;
// }
// },
// color: [
// "#60acfc", // 集成电路 - 蓝色
// "#feb64d", // 人工智能 - 橙色
// "#5bc49f", // 通信网络 - 青色
// "#959cf3", // 量子科技 - 淡蓝紫
// "#ff7c7c", // 先进制造 - 红色
// "#b689ea", // 新材料 - 紫色
// "#32d3eb" // 航空航天 - 浅蓝
// ],
// series: [
// {
// name: "制裁实体领域分布",
// type: "pie",
// radius: [73.5, 89.5],
// center: ["50%", "50%"],
// startAngle: 0,
// data: data,
// label: {
// show: true,
// alignTo: "edge",
// minMargin: 5,
// edgeDistance: 10,
// formatter: params => {
// return "{name|" + params.name + "}\n{value|" + params.value + "家 " + params.percent + "%}";
// },
// rich: {
// name: {
// fontSize: 18,
// fontWeight: 700,
// color: "rgb(59, 65, 75)",
// padding: [0, 0, 5, 0],
// fontFamily: "Microsoft YaHei",
// lineHeight: 26
// },
// value: {
// fontSize: 16,
// fontWeight: 400,
// color: "rgb(95, 101, 108)",
// fontFamily: "Microsoft YaHei",
// lineHeight: 24,
// padding: [5, 0, 0, 0]
// }
// }
// },
// labelLine: {
// show: true,
// length: 15,
// length2: 0,
// maxSurfaceAngle: 80,
// lineStyle: {
// width: 1.1
// }
// },
// labelLayout: {
// hideOverlap: true
// },
// itemStyle: {
// borderWidth: 0
// }
// }
// ]
// };
// chart.setOption(option);
// window.addEventListener("resize", () => {
// chart.resize();
// });
};
const option = {
tooltip: {
trigger: "item",
formatter: params => {
// 如果是“其他”项,展示详细列表
if (params.name === "其他" && params.data.extra) {
let listStr = params.data.extra
.map(item => {
const percent = totalValue ? ((item.value / totalValue) * 100).toFixed(2) : 0;
return `<div style="display:flex;justify-content:space-between;gap:10px;"><span>${item.name}</span><span style="font-weight:bold">${item.value}家 (${percent}%)</span></div>`;
})
.join("");
return `<div style="text-align:left;">
const initDomainChart = () => {
updateDomainChart();
};
const typeChartOption = ref({
tooltip: {
trigger: "item",
formatter: params => {
if (params.name === "其他" && params.data.extra) {
let listStr = params.data.extra
.map(item => {
const percent = totalValue ? ((item.value / totalValue) * 100).toFixed(2) : 0;
return `<div style="display:flex;justify-content:space-between;gap:10px;"><span>${item.name}</span><span style="font-weight:bold">${item.value}家 (${percent}%)</span></div>`;
})
.join("");
return `<div style="text-align:left;">
<div style="font-weight:bold;margin-bottom:5px;">其他 (${params.value}${params.percent}%)</div>
${listStr}
</div>`;
}
// 默认展示
return `${params.name}: ${params.value} (${params.percent}%)`;
}
},
color: [
"#60acfc", // 集成电路 - 蓝色
"#feb64d", // 人工智能 - 橙色
"#5bc49f", // 通信网络 - 青色
"#959cf3", // 量子科技 - 淡蓝紫
"#ff7c7c", // 先进制造 - 红色
"#b689ea", // 新材料 - 紫色
"#32d3eb" // 航空航天 - 浅蓝
],
series: [
{
name: "制裁实体领域分布",
type: "pie",
radius: [73.5, 89.5],
center: ["50%", "50%"],
startAngle: 0,
data: data,
label: {
show: true,
alignTo: "edge",
minMargin: 5,
edgeDistance: 10,
formatter: params => {
return "{name|" + params.name + "}\n{value|" + params.value + "家 " + params.percent + "%}";
},
rich: {
name: {
fontSize: 18,
fontWeight: 700,
color: "rgb(59, 65, 75)",
padding: [0, 0, 5, 0],
fontFamily: "Microsoft YaHei",
lineHeight: 26
},
value: {
fontSize: 16,
fontWeight: 400,
color: "rgb(95, 101, 108)",
fontFamily: "Microsoft YaHei",
lineHeight: 24,
padding: [5, 0, 0, 0]
}
}
return `${params.name}: ${params.value} (${params.percent}%)`;
}
},
color: [
"#3B82F6", // 企业 - 蓝色
"#feb64d", // 高校 - 橙色
"#ff9f9f" // 科研院所 - 粉红
],
series: [
{
name: "制裁实体类型分布",
type: "pie",
radius: [73.5, 89.5],
center: ["50%", "50%"],
startAngle: -90,
data: [],
label: {
show: true,
alignTo: "edge",
minMargin: 5,
edgeDistance: 10,
formatter: params => {
return "{name|" + params.name + "}\n{value|" + params.value + "家 " + params.percent + "%}";
},
labelLine: {
show: true,
length: 15,
length2: 0,
maxSurfaceAngle: 80,
lineStyle: {
width: 1.1
rich: {
name: {
fontSize: 18,
fontWeight: 700,
color: "rgb(59, 65, 75)",
padding: [0, 0, 5, 0],
fontFamily: "Microsoft YaHei",
lineHeight: 26
},
value: {
fontSize: 16,
fontWeight: 400,
color: "rgb(95, 101, 108)",
fontFamily: "Microsoft YaHei",
lineHeight: 24,
padding: [5, 0, 0, 0]
}
},
labelLayout: {
hideOverlap: true
},
itemStyle: {
borderWidth: 0
}
},
labelLine: {
show: true,
length: 15,
length2: 0,
maxSurfaceAngle: 80,
lineStyle: {
width: 1
}
},
labelLayout: function (params) {
const isLeft = params.labelRect.x < chart.getWidth() / 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
};
},
itemStyle: {
borderWidth: 0
}
]
};
chart.setOption(option);
window.addEventListener("resize", () => {
chart.resize();
});
};
const initDomainChart = () => {
updateDomainChart();
};
}
]
});
const updateTypeChart = () => {
if (!typeChartRef.value) return;
let chart = echarts.getInstanceByDom(typeChartRef.value);
if (!chart) {
chart = echarts.init(typeChartRef.value);
}
// if (!typeChartRef.value) return;
// let chart = echarts.getInstanceByDom(typeChartRef.value);
// if (!chart) {
// chart = echarts.init(typeChartRef.value);
// }
let data = typeData.value.length
? [...typeData.value]
......@@ -765,7 +953,7 @@ const updateTypeChart = () => {
{ value: 50, name: "企业" },
{ value: 32, name: "高校" },
{ value: 32, name: "科研院所" }
];
];
// 2. 聚合逻辑:保留前5项,其余合并为“其他”
data.sort((a, b) => b.value - a.value);
......@@ -780,105 +968,105 @@ const updateTypeChart = () => {
const otherData = data.slice(TOP_N);
const otherValue = otherData.reduce((sum, item) => sum + (item.value || 0), 0);
if (otherValue > 0) {
topData.push({
name: "其他",
value: otherValue,
extra: otherData
});
}
data = topData;
// if (otherValue > 0) {
// topData.push({
// name: "其他",
// value: otherValue,
// extra: otherData
// });
// }
typeChartOption.value.series[0].data = topData;
}
const option = {
tooltip: {
trigger: "item",
formatter: params => {
if (params.name === "其他" && params.data.extra) {
let listStr = params.data.extra
.map(item => {
const percent = totalValue ? ((item.value / totalValue) * 100).toFixed(2) : 0;
return `<div style="display:flex;justify-content:space-between;gap:10px;"><span>${item.name}</span><span style="font-weight:bold">${item.value}家 (${percent}%)</span></div>`;
})
.join("");
return `<div style="text-align:left;">
<div style="font-weight:bold;margin-bottom:5px;">其他 (${params.value}${params.percent}%)</div>
${listStr}
</div>`;
}
return `${params.name}: ${params.value} (${params.percent}%)`;
}
},
color: [
"#3B82F6", // 企业 - 蓝色
"#feb64d", // 高校 - 橙色
"#ff9f9f" // 科研院所 - 粉红
],
series: [
{
name: "制裁实体类型分布",
type: "pie",
radius: [73.5, 89.5],
center: ["50%", "50%"],
startAngle: -90,
data: data,
label: {
show: true,
alignTo: "edge",
minMargin: 5,
edgeDistance: 10,
formatter: params => {
return "{name|" + params.name + "}\n{value|" + params.value + "家 " + params.percent + "%}";
},
rich: {
name: {
fontSize: 18,
fontWeight: 700,
color: "rgb(59, 65, 75)",
padding: [0, 0, 5, 0],
fontFamily: "Microsoft YaHei",
lineHeight: 26
},
value: {
fontSize: 16,
fontWeight: 400,
color: "rgb(95, 101, 108)",
fontFamily: "Microsoft YaHei",
lineHeight: 24,
padding: [5, 0, 0, 0]
}
}
},
labelLine: {
show: true,
length: 15,
length2: 0,
maxSurfaceAngle: 80,
lineStyle: {
width: 1
}
},
labelLayout: function (params) {
const isLeft = params.labelRect.x < chart.getWidth() / 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
};
},
itemStyle: {
borderWidth: 0
}
}
]
};
chart.setOption(option);
window.addEventListener("resize", () => {
chart.resize();
});
// const option = {
// tooltip: {
// trigger: "item",
// formatter: params => {
// if (params.name === "其他" && params.data.extra) {
// let listStr = params.data.extra
// .map(item => {
// const percent = totalValue ? ((item.value / totalValue) * 100).toFixed(2) : 0;
// return `<div style="display:flex;justify-content:space-between;gap:10px;"><span>${item.name}</span><span style="font-weight:bold">${item.value}家 (${percent}%)</span></div>`;
// })
// .join("");
// return `<div style="text-align:left;">
// <div style="font-weight:bold;margin-bottom:5px;">其他 (${params.value} ${params.percent}%)</div>
// ${listStr}
// </div>`;
// }
// return `${params.name}: ${params.value} (${params.percent}%)`;
// }
// },
// color: [
// "#3B82F6", // 企业 - 蓝色
// "#feb64d", // 高校 - 橙色
// "#ff9f9f" // 科研院所 - 粉红
// ],
// series: [
// {
// name: "制裁实体类型分布",
// type: "pie",
// radius: [73.5, 89.5],
// center: ["50%", "50%"],
// startAngle: -90,
// data: data,
// label: {
// show: true,
// alignTo: "edge",
// minMargin: 5,
// edgeDistance: 10,
// formatter: params => {
// return "{name|" + params.name + "}\n{value|" + params.value + "家 " + params.percent + "%}";
// },
// rich: {
// name: {
// fontSize: 18,
// fontWeight: 700,
// color: "rgb(59, 65, 75)",
// padding: [0, 0, 5, 0],
// fontFamily: "Microsoft YaHei",
// lineHeight: 26
// },
// value: {
// fontSize: 16,
// fontWeight: 400,
// color: "rgb(95, 101, 108)",
// fontFamily: "Microsoft YaHei",
// lineHeight: 24,
// padding: [5, 0, 0, 0]
// }
// }
// },
// labelLine: {
// show: true,
// length: 15,
// length2: 0,
// maxSurfaceAngle: 80,
// lineStyle: {
// width: 1
// }
// },
// labelLayout: function (params) {
// const isLeft = params.labelRect.x < chart.getWidth() / 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
// };
// },
// itemStyle: {
// borderWidth: 0
// }
// }
// ]
// };
// chart.setOption(option);
// window.addEventListener("resize", () => {
// chart.resize();
// });
};
const initTypeChart = () => {
......@@ -1315,4 +1503,50 @@ onMounted(() => {
font-size: 14px;
}
}
.data-origin-box {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 22px 0;
.data-origin-icon {
width: 16px;
height: 16px;
font-size: 0px;
margin-right: 8px;
img {
width: 100%;
height: 100%;
}
}
.data-origin-text {
font-family: Source Han Sans CN;
font-size: 14px;
color: var(--text-primary-50-color);
}
}
.ai-pane {
position: absolute;
right: 0px;
bottom: 15px;
z-index: 2;
:deep(.ai-pane-wrapper) {
display: none;
}
:deep(.ai-button-wrapper) {
display: flex;
}
&:hover {
width: 100%;
bottom: 0px;
:deep(.ai-pane-wrapper) {
display: block;
}
:deep(.ai-button-wrapper) {
display: none;
}
}
}
</style>
......@@ -12,7 +12,7 @@
{{ headerTitle.department }}
</div>
</div>
<div class="btn"><img :src="icon01" alt="" />切换</div>
<!-- <div class="btn"><img :src="icon01" alt="" />切换</div> -->
</div>
<div class="header-nav">
<div
......
......@@ -42,8 +42,8 @@
<div class="main">
<div class="main-item">
<AnalysisBox title="制裁实体领域分布情况">
<div class="echarts" ref="domainChartRef"></div>
<div class="bottom">
<!-- <div class="echarts" ref="domainChartRef"></div> -->
<!-- <div class="bottom">
<div class="ai">
<div class="left">
<img :src="ai" alt="" class="icon1" />
......@@ -53,6 +53,17 @@
<img :src="right" alt="" class="icon2" />
</div>
</div>
</div> -->
<EChart :option="domainChartOption" autoresize :style="{ height: '300px' }" />
<div class="data-origin-box">
<div class="data-origin-icon">
<img :src="tipsIcon" alt="" />
</div>
<div class="data-origin-text">制裁实体领域分布情况,数据来源:美国各行政机构官网</div>
</div>
<div class="ai-pane">
<AiButton />
<AiPane :aiContent="domainChart.interpretation" />
</div>
</AnalysisBox>
</div>
......@@ -81,7 +92,7 @@
</div>
</div> -->
<AnalysisBox title="制裁实体类型分布情况">
<div class="echarts" ref="typeChartRef"></div>
<!-- <div class="echarts" ref="typeChartRef"></div>
<div class="bottom">
<div class="ai">
<div class="left">
......@@ -92,48 +103,21 @@
<img :src="right" alt="" class="icon2" />
</div>
</div>
</div> -->
<EChart :option="typeChartOption" autoresize :style="{ height: '300px' }" />
<div class="data-origin-box">
<div class="data-origin-icon">
<img :src="tipsIcon" alt="" />
</div>
<div class="data-origin-text">制裁实体类型分布情况,数据来源:美国各行政机构官网</div>
</div>
<div class="ai-pane">
<AiButton />
<AiPane :aiContent="typeChart.interpretation" />
</div>
</AnalysisBox>
</div>
<div class="main-item">
<!-- <div class="title-com">
<div class="box"></div>
<div class="text">制裁实体国家分布情况</div>
<div class="right-group">
<div class="btn">
<img src="../../assets/数据库按钮.png" alt="" />
<img src="../../assets/下载按钮.png" alt="" />
<img src="../../assets/收藏按钮.png" alt="" />
</div>
</div>
</div> -->
<!-- <div class="country-list">
<div class="list-item" v-for="(item, index) in countryDistribution" :key="index">
<img :src="flag" alt="" class="flag">
<div class="country-name">{{ item.name }}</div>
<div class="progress-bar-container">
<div
class="progress-bar"
:style="{
width: item.width,
background: item.gradient
}"
></div>
</div>
<div class="count" :class="{ 'highlight': index === 0 }">{{ item.count }}</div>
</div>
</div>
<div class="bottom">
<div class="ai">
<div class="left">
<img :src="ai" alt="" class="icon1" />
<div class="text">美国对中国的制裁近年来呈现显著增长趋势。</div>
</div>
<div class="right">
<img :src="right" alt="" class="icon2" />
</div>
</div>
</div> -->
<AnalysisBox title="制裁实体国家分布情况">
<div class="country-list">
<div class="list-item" v-for="(item, index) in countryDistribution" :key="index">
......@@ -151,7 +135,7 @@
<div class="count" :class="{ highlight: index === 0 }">{{ item.count }}</div>
</div>
</div>
<div class="bottom">
<!-- <div class="bottom">
<div class="ai">
<div class="left">
<img :src="ai" alt="" class="icon1" />
......@@ -161,6 +145,16 @@
<img :src="right" alt="" class="icon2" />
</div>
</div>
</div> -->
<div class="data-origin-box">
<div class="data-origin-icon">
<img :src="tipsIcon" alt="" />
</div>
<div class="data-origin-text">制裁实体国家分布情况,数据来源:美国各行政机构官网</div>
</div>
<div class="ai-pane">
<AiButton />
<AiPane :aiContent="countryDistributionChart.interpretation" />
</div>
</AnalysisBox>
</div>
......@@ -185,7 +179,7 @@
</div>
</div>
</div>
<div class="bottom">
<!-- <div class="bottom">
<div class="ai">
<div class="left">
<img :src="ai" alt="" class="icon1" />
......@@ -195,6 +189,16 @@
<img :src="right" alt="" class="icon2" />
</div>
</div>
</div> -->
<div class="data-origin-box">
<div class="data-origin-icon">
<img :src="tipsIcon" alt="" />
</div>
<div class="data-origin-text">制裁实体地域分布情况,数据来源:美国各行政机构官网</div>
</div>
<div class="ai-pane">
<AiButton />
<AiPane :aiContent="regionDistributionChart.interpretation" />
</div>
</AnalysisBox>
</div>
......@@ -206,10 +210,14 @@
import { ref, onMounted, watch } from "vue";
import * as echarts from "echarts";
import chinaJson from "../../../utils/China.json";
import tipsIcon from "../../../assets/icons/info-icon.png";
import ai from "./assets/ai.png";
import right from "./assets/right.png";
import flag from "../../assets/default-icon2.png";
import { useRouter } from "vue-router";
import AiButton from "@/components/base/Ai/AiButton/index.vue";
import AiPane from "@/components/base/Ai/AiPane/index.vue";
import EChart from "@/components/Chart/index.vue";
import { useRouter, useRoute } from "vue-router";
import {
getSingleSanctionTotalCount,
getSingleSanctionDomainCount,
......@@ -217,7 +225,13 @@ import {
getSingleSanctionEntityCountryCount,
getSingleSanctionEntityRegionCount
} from "@/api/exportControlV2.0";
import { useRoute } from "vue-router";
import { useChartInterpretation } from "@/views/exportControl/utils/common";
const sanctionCountChart = useChartInterpretation();
const domainChart = useChartInterpretation();
const typeChart = useChartInterpretation();
const countryDistributionChart = useChartInterpretation();
const regionDistributionChart = useChartInterpretation();
const route = useRoute();
// 单次制裁-数据统计-制裁实体地域分布情况
......@@ -237,6 +251,7 @@ const getRegionData = async () => {
regionDistribution.value = res.data || [];
maxRegionCount.value = Math.max(...regionDistribution.value.map(item => item.count), 0);
initMapChart();
regionDistributionChart.interpret({ type: "柱状图", name: "制裁实体地域分布情况", data: res.data });
}
} catch (error) {
console.log(error);
......@@ -276,6 +291,7 @@ const getCountryCount = async () => {
gradient
};
});
countryDistributionChart.interpret({ type: "柱状图", name: "制裁实体国家分布情况", data: res.data });
}
} catch (error) {
console.log(error);
......@@ -296,6 +312,7 @@ const getEntityTypeCount = async () => {
const res = await getSingleSanctionEntityTypeCount(params);
if (res.code === 200) {
entityTypeCount.value = res.data || [];
typeChart.interpret({ type: "饼图", name: "制裁实体类型分布情况", data: entityTypeCount.value });
initTypeChart();
}
} catch (error) {
......@@ -318,6 +335,7 @@ const getDomainCount = async () => {
if (res.code === 200) {
domainCount.value = res.data || [];
initDomainChart();
domainChart.interpret({ type: "饼图", name: "制裁实体领域分布情况", data: domainCount.value });
}
} catch (error) {
console.log(error);
......@@ -457,190 +475,346 @@ const initMapChart = () => {
});
};
const domainChartOption = ref({
tooltip: {
trigger: "item",
formatter: "{b}: {c} ({d}%)"
},
color: [
"#60acfc", // 集成电路 - 蓝色
"#feb64d", // 人工智能 - 橙色
"#5bc49f", // 通信网络 - 青色
"#959cf3", // 量子科技 - 淡蓝紫
"#ff7c7c", // 先进制造 - 红色
"#b689ea", // 新材料 - 紫色
"#32d3eb" // 航空航天 - 浅蓝
],
series: [
{
name: "制裁实体领域分布",
type: "pie",
radius: [65, 81],
center: ["50%", "50%"],
startAngle: 90,
data: [],
label: {
show: true,
alignTo: "edge",
minMargin: 5,
edgeDistance: 10,
formatter: params => {
return "{name|" + params.name + "} \n {value|" + params.value + "家 " + params.percent + "%}";
},
rich: {
name: {
fontSize: 18,
fontWeight: 700,
color: "rgb(59, 65, 75)",
padding: [10, 0, 10, 0],
fontFamily: "Microsoft YaHei",
lineHeight: 22
},
value: {
fontSize: 16,
fontWeight: 400,
color: "rgb(95, 101, 108)",
fontFamily: "Microsoft YaHei",
lineHeight: 22,
padding: [10, 0, 10, 0]
}
}
},
labelLine: {
show: true,
length: 15,
length2: 0,
maxSurfaceAngle: 80,
lineStyle: {
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
};
// if (isLeft) {
// // 左侧:终点对齐到标签左边界
// points[2][0] = 0;
// } else {
// // 右侧:终点对齐到标签右边界
// points[2][0] = params.labelRect.x + params.labelRect.width;
// }
// return {
// labelLinePoints: points
// };
},
itemStyle: {
borderWidth: 0
}
}
]
});
const initDomainChart = () => {
if (!domainChartRef.value) return;
// 销毁旧实例,防止内存泄漏或状态残留
const oldChart = echarts.getInstanceByDom(domainChartRef.value);
if (oldChart) {
oldChart.dispose();
}
const chart = echarts.init(domainChartRef.value);
// if (!domainChartRef.value) return;
// // 销毁旧实例,防止内存泄漏或状态残留
// const oldChart = echarts.getInstanceByDom(domainChartRef.value);
// if (oldChart) {
// oldChart.dispose();
// }
// const chart = echarts.init(domainChartRef.value);
const data = domainCount.value.map(item => ({
value: item.count,
name: item.name
}));
domainChartOption.value.series[0].data = data;
// const option = {
// tooltip: {
// trigger: "item",
// formatter: "{b}: {c} ({d}%)"
// },
// color: [
// "#60acfc", // 集成电路 - 蓝色
// "#feb64d", // 人工智能 - 橙色
// "#5bc49f", // 通信网络 - 青色
// "#959cf3", // 量子科技 - 淡蓝紫
// "#ff7c7c", // 先进制造 - 红色
// "#b689ea", // 新材料 - 紫色
// "#32d3eb" // 航空航天 - 浅蓝
// ],
// series: [
// {
// name: "制裁实体领域分布",
// type: "pie",
// radius: [65, 81],
// center: ["50%", "50%"],
// startAngle: 90,
// data: data,
// label: {
// show: true,
// alignTo: "edge",
// minMargin: 5,
// edgeDistance: 10,
// formatter: params => {
// return "{name|" + params.name + "} \n {value|" + params.value + "家 " + params.percent + "%}";
// },
// rich: {
// name: {
// fontSize: 18,
// fontWeight: 700,
// color: "rgb(59, 65, 75)",
// padding: [10, 0, 10, 0],
// fontFamily: "Microsoft YaHei",
// lineHeight: 22
// },
// value: {
// fontSize: 16,
// fontWeight: 400,
// color: "rgb(95, 101, 108)",
// fontFamily: "Microsoft YaHei",
// lineHeight: 22,
// padding: [10, 0, 10, 0]
// }
// }
// },
// labelLine: {
// show: true,
// length: 15,
// length2: 0,
// maxSurfaceAngle: 80,
// lineStyle: {
// width: 1.1
// }
// },
// labelLayout: function (params) {
// const isLeft = params.labelRect.x < chart.getWidth() / 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
// };
// },
// itemStyle: {
// borderWidth: 0
// }
// }
// ]
// };
// chart.setOption(option);
// window.addEventListener("resize", () => {
// chart.resize();
// });
};
const option = {
tooltip: {
trigger: "item",
formatter: "{b}: {c} ({d}%)"
},
color: [
"#60acfc", // 集成电路 - 蓝色
"#feb64d", // 人工智能 - 橙色
"#5bc49f", // 通信网络 - 青色
"#959cf3", // 量子科技 - 淡蓝紫
"#ff7c7c", // 先进制造 - 红色
"#b689ea", // 新材料 - 紫色
"#32d3eb" // 航空航天 - 浅蓝
],
series: [
{
name: "制裁实体领域分布",
type: "pie",
radius: [65, 81],
center: ["50%", "50%"],
startAngle: 90,
data: data,
label: {
show: true,
alignTo: "edge",
minMargin: 5,
edgeDistance: 10,
formatter: params => {
return "{name|" + params.name + "} \n {value|" + params.value + "家 " + params.percent + "%}";
},
rich: {
name: {
fontSize: 18,
fontWeight: 700,
color: "rgb(59, 65, 75)",
padding: [10, 0, 10, 0],
fontFamily: "Microsoft YaHei",
lineHeight: 22
},
value: {
fontSize: 16,
fontWeight: 400,
color: "rgb(95, 101, 108)",
fontFamily: "Microsoft YaHei",
lineHeight: 22,
padding: [10, 0, 10, 0]
}
}
const typeChartOption = ref({
tooltip: {
trigger: "item",
formatter: "{b}: {c} ({d}%)"
},
color: [
"#3B82F6", // 企业 - 蓝色
"#feb64d", // 高校 - 橙色
"#ff9f9f" // 科研院所 - 粉红
],
series: [
{
name: "制裁实体类型分布",
type: "pie",
radius: [73.5, 89.5],
center: ["50%", "50%"],
startAngle: -90,
data: [],
label: {
show: true,
alignTo: "edge",
minMargin: 5,
edgeDistance: 10,
formatter: params => {
return "{name|" + params.name + "}\n{value|" + params.value + "家 " + params.percent + "%}";
},
labelLine: {
show: true,
length: 15,
length2: 0,
maxSurfaceAngle: 80,
lineStyle: {
width: 1.1
rich: {
name: {
fontSize: 18,
fontWeight: 700,
color: "rgb(59, 65, 75)",
padding: [0, 0, 5, 0],
fontFamily: "Microsoft YaHei",
lineHeight: 26
},
value: {
fontSize: 16,
fontWeight: 400,
color: "rgb(95, 101, 108)",
fontFamily: "Microsoft YaHei",
lineHeight: 24,
padding: [5, 0, 0, 0]
}
},
labelLayout: function (params) {
const isLeft = params.labelRect.x < chart.getWidth() / 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
};
},
itemStyle: {
borderWidth: 0
}
},
labelLine: {
show: true,
length: 15,
length2: 0,
maxSurfaceAngle: 80,
lineStyle: {
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
};
},
itemStyle: {
borderWidth: 0
}
]
};
chart.setOption(option);
window.addEventListener("resize", () => {
chart.resize();
});
};
}
]
});
const initTypeChart = () => {
if (!typeChartRef.value) return;
// 销毁旧实例,防止内存泄漏或状态残留
const oldChart = echarts.getInstanceByDom(typeChartRef.value);
if (oldChart) {
oldChart.dispose();
}
const chart = echarts.init(typeChartRef.value);
// if (!typeChartRef.value) return;
// // 销毁旧实例,防止内存泄漏或状态残留
// const oldChart = echarts.getInstanceByDom(typeChartRef.value);
// if (oldChart) {
// oldChart.dispose();
// }
// const chart = echarts.init(typeChartRef.value);
const data = entityTypeCount.value.map(item => ({
value: item.count,
name: item.name
}));
const option = {
tooltip: {
trigger: "item",
formatter: "{b}: {c} ({d}%)"
},
color: [
"#3B82F6", // 企业 - 蓝色
"#feb64d", // 高校 - 橙色
"#ff9f9f" // 科研院所 - 粉红
],
series: [
{
name: "制裁实体类型分布",
type: "pie",
radius: [73.5, 89.5],
center: ["50%", "50%"],
startAngle: -90,
data: data,
label: {
show: true,
alignTo: "edge",
minMargin: 5,
edgeDistance: 10,
formatter: params => {
return "{name|" + params.name + "}\n{value|" + params.value + "家 " + params.percent + "%}";
},
rich: {
name: {
fontSize: 18,
fontWeight: 700,
color: "rgb(59, 65, 75)",
padding: [0, 0, 5, 0],
fontFamily: "Microsoft YaHei",
lineHeight: 26
},
value: {
fontSize: 16,
fontWeight: 400,
color: "rgb(95, 101, 108)",
fontFamily: "Microsoft YaHei",
lineHeight: 24,
padding: [5, 0, 0, 0]
}
}
},
labelLine: {
show: true,
length: 15,
length2: 0,
maxSurfaceAngle: 80,
lineStyle: {
width: 1
}
},
labelLayout: function (params) {
const isLeft = params.labelRect.x < chart.getWidth() / 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
};
},
itemStyle: {
borderWidth: 0
}
}
]
};
chart.setOption(option);
window.addEventListener("resize", () => {
chart.resize();
});
typeChartOption.value.series[0].data = data;
// const option = {
// tooltip: {
// trigger: "item",
// formatter: "{b}: {c} ({d}%)"
// },
// color: [
// "#3B82F6", // 企业 - 蓝色
// "#feb64d", // 高校 - 橙色
// "#ff9f9f" // 科研院所 - 粉红
// ],
// series: [
// {
// name: "制裁实体类型分布",
// type: "pie",
// radius: [73.5, 89.5],
// center: ["50%", "50%"],
// startAngle: -90,
// data: data,
// label: {
// show: true,
// alignTo: "edge",
// minMargin: 5,
// edgeDistance: 10,
// formatter: params => {
// return "{name|" + params.name + "}\n{value|" + params.value + "家 " + params.percent + "%}";
// },
// rich: {
// name: {
// fontSize: 18,
// fontWeight: 700,
// color: "rgb(59, 65, 75)",
// padding: [0, 0, 5, 0],
// fontFamily: "Microsoft YaHei",
// lineHeight: 26
// },
// value: {
// fontSize: 16,
// fontWeight: 400,
// color: "rgb(95, 101, 108)",
// fontFamily: "Microsoft YaHei",
// lineHeight: 24,
// padding: [5, 0, 0, 0]
// }
// }
// },
// labelLine: {
// show: true,
// length: 15,
// length2: 0,
// maxSurfaceAngle: 80,
// lineStyle: {
// width: 1
// }
// },
// labelLayout: function (params) {
// const isLeft = params.labelRect.x < chart.getWidth() / 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
// };
// },
// itemStyle: {
// borderWidth: 0
// }
// }
// ]
// };
// chart.setOption(option);
// window.addEventListener("resize", () => {
// chart.resize();
// });
};
const sanTypeId = ref("");
onMounted(() => {
......@@ -1086,4 +1260,49 @@ onMounted(() => {
}
}
}
.data-origin-box {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 22px 0;
.data-origin-icon {
width: 16px;
height: 16px;
font-size: 0px;
margin-right: 8px;
img {
width: 100%;
height: 100%;
}
}
.data-origin-text {
font-family: Source Han Sans CN;
font-size: 14px;
color: var(--text-primary-50-color);
}
}
.ai-pane {
position: absolute;
right: 0px;
bottom: 15px;
z-index: 2;
:deep(.ai-pane-wrapper) {
display: none;
}
:deep(.ai-button-wrapper) {
display: flex;
}
&:hover {
width: 100%;
bottom: 0px;
:deep(.ai-pane-wrapper) {
display: block;
}
:deep(.ai-button-wrapper) {
display: none;
}
}
}
</style>
......@@ -23,7 +23,7 @@
</div>
</div>
<div ref="containerRef" class="graph-container"></div>
<!-- <GraphChart v-if="currentLayoutType === 1" :nodes="graphNodes" :links="graphLinks" /> -->
<div v-if="selectedNode" class="node-popup">
<div class="popup-header">
<img :src="selectedNode.image || defaultIcon" alt="" class="popup-icon" />
......@@ -55,6 +55,8 @@ import echartsIcon02 from "../assets/echartsIcon02.png";
import echartsIcon03 from "../assets/echartsIcon03.png";
import defaultIcon from "../assets/echartsIcon03.png";
// import GraphChart from "@/components/base/GraphChart/index.vue";
import GraphChart from "@/views/companyPages2/component/SupplyChain/GraphChart.vue";
import { getSingleSanctionEntityInfo } from "@/api/exportControlV2.0";
const props = defineProps({
......@@ -107,7 +109,7 @@ const initGraph = (layoutType = 1) => {
const initNormalGraph = (layoutType, width, height) => {
const data = processGraphData(props.graphData);
console.log("初始数据", props.graphData);
if (!data.nodes || data.nodes.length === 0) return;
const layout = {
......@@ -653,6 +655,56 @@ const bindGraphEvents = () => {
});
};
// 将传入的 graphData 转换为 GraphChart.vue 需要的格式
// GraphChart.vue 需要节点有 x, y 坐标,所以我们需要一个简单的布局算法
const graphNodes = ref([]);
const graphLinks = ref([]);
const applyStarLayout = (rawNodes, rawLinks) => {
if (!rawNodes?.length) return { nodes: [], links: [] };
const centerX = 0;
const centerY = 0;
const radius = 200; // 环绕半径
const nodes = rawNodes.map((node, index) => {
let x, y;
if (index === 0) {
// 中心节点
x = centerX;
y = centerY;
} else {
// 其他节点均匀分布在圆周上
const angle = (2 * Math.PI * (index - 1)) / (rawNodes.length - 1);
x = centerX + radius * Math.cos(angle);
y = centerY + radius * Math.sin(angle);
}
return {
id: String(node.id ?? index),
name: node.name || "",
type: node.isSanctioned ? "sanctioned" : "normal", // 用于区分样式(如果需要)
symbolSize: node.symbolSize || (index === 0 ? 60 : 40),
symbol: `image://${node.image || defaultIcon}`,
isSanctioned: node.isSanctioned,
image: node.image || defaultIcon,
x: x,
y: y,
...node // 透传其他字段
};
});
const links = (rawLinks || []).map(link => ({
source: String(link.source),
target: String(link.target),
type: link.name || "relation", // GraphChart.vue 使用 type 字段
label: {
show: true,
formatter: link.name || ""
}
}));
return { nodes, links };
};
const handleClickControlBtn = btn => {
currentLayoutType.value = btn;
emit("layoutChange", btn);
......@@ -675,6 +727,16 @@ const handleResize = () => {
}
};
watch(
() => props.graphData,
newVal => {
const { nodes, links } = applyStarLayout(newVal.nodes, newVal.links);
graphNodes.value = JSON.parse(JSON.stringify(nodes));
graphLinks.value = JSON.parse(JSON.stringify(links));
},
{ deep: true, immediate: true }
);
watch(
() => props.graphData,
() => {
......
......@@ -72,8 +72,13 @@
<div class="company-list-container">
<div class="list-header">企业名称</div>
<div class="company-list">
<div class="company-item" :class="{ active: selectedCompanyId === item.id }" v-for="item in entityList"
:key="item.id" @click="selectedCompanyId = item.id">
<div
class="company-item"
:class="{ active: selectedCompanyId === item.id }"
v-for="item in entityList"
:key="item.id"
@click="selectedCompanyId = item.id"
>
<div class="icon-wrapper">
<img :src="defaultTitle" alt="" />
</div>
......@@ -114,18 +119,34 @@
<AnalysisBox title="企业规模">
<template #header-btn>
<div class="toggle-btns">
<div class="t-btn" :class="{ active: activeScale === item }" v-for="item in scaleOptions" :key="item"
@click="handleScaleClick(item)">
<div
class="t-btn"
:class="{ active: activeScale === item }"
v-for="item in scaleOptions"
:key="item"
@click="handleScaleClick(item)"
>
{{ item }}
</div>
</div>
</template>
<div class="right-main">
<div class="echarts" ref="chartRef"></div>
<div class="bottom">
<!-- <div class="echarts" ref="chartRef"></div> -->
<!-- <div class="bottom">
<img :src="ai" class="ai-icon" alt="" />
<span class="text">列入实体清单后企业营收初期下降,后基本趋于稳定。</span>
<img :src="right" class="right-icon" alt="" />
</div> -->
<EChart :option="revenueChartOption" autoresize :style="{ height: '300px' }" />
<div class="data-origin-box">
<div class="data-origin-icon">
<img :src="tipsIcon" alt="" />
</div>
<div class="data-origin-text">企业规模情况,数据来源:美国各行政机构官网</div>
</div>
<div class="ai-pane">
<AiButton />
<AiPane :aiContent="revenueChart.interpretation" />
</div>
</div>
</AnalysisBox>
......@@ -152,11 +173,22 @@
</div> -->
<AnalysisBox title="企业市值变化">
<div class="right-main">
<div class="echarts" ref="marketChartRef"></div>
<!-- <div class="echarts" ref="marketChartRef"></div>
<div class="bottom">
<img :src="ai" class="ai-icon" alt="" />
<span class="text">列入实体清单后企业营收初期下降,后基本趋于稳定。</span>
<img :src="right" class="right-icon" alt="" />
</div> -->
<EChart :option="marketChartOption" autoresize :style="{ height: '300px' }" />
<div class="data-origin-box">
<div class="data-origin-icon">
<img :src="tipsIcon" alt="" />
</div>
<div class="data-origin-text">企业市值变化情况,数据来源:美国各行政机构官网</div>
</div>
<div class="ai-pane">
<AiButton />
<AiPane :aiContent="marketChart.interpretation" />
</div>
</div>
</AnalysisBox>
......@@ -190,18 +222,34 @@
<AnalysisBox title="企业研发投入">
<template #header-btn>
<div class="toggle-btns">
<div class="t-btn" :class="{ active: activeRD === item }" v-for="item in rdOptions" :key="item"
@click="activeRD = item">
<div
class="t-btn"
:class="{ active: activeRD === item }"
v-for="item in rdOptions"
:key="item"
@click="activeRD = item"
>
{{ item }}
</div>
</div>
</template>
<div class="right-main">
<div class="echarts" ref="rdChartRef"></div>
<!-- <div class="echarts" ref="rdChartRef"></div>
<div class="bottom">
<img :src="ai" class="ai-icon" alt="" />
<span class="text">列入实体清单后企业研发资金投入逐渐提高。</span>
<img :src="right" class="right-icon" alt="" />
</div> -->
<EChart :option="rdChartOption" autoresize :style="{ height: '300px' }" />
<div class="data-origin-box">
<div class="data-origin-icon">
<img :src="tipsIcon" alt="" />
</div>
<div class="data-origin-text">企业研发投入情况,数据来源:美国各行政机构官网</div>
</div>
<div class="ai-pane">
<AiButton />
<AiPane :aiContent="rdChart.interpretation" />
</div>
</div>
</AnalysisBox>
......@@ -232,23 +280,39 @@
<img :src="right" class="right-icon" alt="" />
</div>
</div> -->
<AnalysisBox title="企业市场占比">
<AnalysisBox title="企业市场占比">
<template #header-btn>
<div class="toggle-btns">
<div class="t-btn" :class="{ active: activeMarketShare === item }" v-for="item in marketShareOptions"
:key="item" @click="activeMarketShare = item">
<div class="toggle-btns">
<div
class="t-btn"
:class="{ active: activeMarketShare === item }"
v-for="item in marketShareOptions"
:key="item"
@click="activeMarketShare = item"
>
{{ item }}
</div>
</div>
</template>
<div class="right-main">
<div class="echarts" ref="shareChartRef"></div>
<div class="bottom">
<img :src="ai" class="ai-icon" alt="" />
<span class="text">列入实体清单后企业营收初期下降,后基本趋于稳定。</span>
<img :src="right" class="right-icon" alt="" />
<!-- <div class="echarts" ref="shareChartRef"></div>
<div class="bottom">
<img :src="ai" class="ai-icon" alt="" />
<span class="text">列入实体清单后企业营收初期下降,后基本趋于稳定。</span>
<img :src="right" class="right-icon" alt="" />
</div> -->
<EChart :option="shareChartOption" autoresize :style="{ height: '300px' }" />
<div class="data-origin-box">
<div class="data-origin-icon">
<img :src="tipsIcon" alt="" />
</div>
<div class="data-origin-text">企业市场占比情况,数据来源:美国各行政机构官网</div>
</div>
<div class="ai-pane">
<AiButton />
<AiPane :aiContent="shareChart.interpretation" />
</div>
</div>
</div>
</AnalysisBox>
</div>
</div>
......@@ -264,8 +328,26 @@ import { Search } from "@element-plus/icons-vue";
import defaultTitle from "../../../../assets/default-icon2.png";
import ai from "../../assets/ai.png";
import right from "../../assets/right.png";
import { getSingleSanctionEntityList, getSingleSanctionEntityRevenue, getSingleSanctionEntityNetProfit, getSingleSanctionEntityPersonnel, getSingleSanctionEntityMarketValue, getSingleSanctionEntityRDInvestment, getSingleSanctionEntityMarketShare } from "@/api/exportControlV2.0";
import tipsIcon from "../../../../../assets/icons/info-icon.png";
import {
getSingleSanctionEntityList,
getSingleSanctionEntityRevenue,
getSingleSanctionEntityNetProfit,
getSingleSanctionEntityPersonnel,
getSingleSanctionEntityMarketValue,
getSingleSanctionEntityRDInvestment,
getSingleSanctionEntityMarketShare
} from "@/api/exportControlV2.0";
import AiButton from "@/components/base/Ai/AiButton/index.vue";
import AiPane from "@/components/base/Ai/AiPane/index.vue";
import EChart from "@/components/Chart/index.vue";
import { useChartInterpretation } from "@/views/exportControl/utils/common";
const revenueChart = useChartInterpretation();
const marketChart = useChartInterpretation();
const rdChart = useChartInterpretation();
const shareChart = useChartInterpretation();
// 单次制裁-影响分析-企业市场占比
const marketShareData = ref([]);
......@@ -273,8 +355,8 @@ const getMarketShare = async () => {
if (!selectedCompanyId.value) return;
try {
const res = await getSingleSanctionEntityMarketShare({
id: selectedCompanyId.value,
})
id: selectedCompanyId.value
});
if (res.code === 200) {
marketShareData.value = res.data || [];
......@@ -291,15 +373,13 @@ const getMarketShare = async () => {
nextTick(() => {
initShareChart();
});
shareChart.interpret({ type: "柱状图", name: "企业市场占比", data: sortedData });
}
}
} catch (error) {
console.log(error)
console.log(error);
}
}
};
// 单次制裁-影响分析-企业研发投入
const rdInvestmentData = ref([]);
......@@ -307,8 +387,8 @@ const getRDInvestment = async () => {
if (!selectedCompanyId.value) return;
try {
const res = await getSingleSanctionEntityRDInvestment({
id: selectedCompanyId.value,
})
id: selectedCompanyId.value
});
if (res.code === 200) {
rdInvestmentData.value = res.data || [];
......@@ -327,15 +407,13 @@ const getRDInvestment = async () => {
nextTick(() => {
initRDChart();
});
rdChart.interpret({ type: "折线图", name: "企业研发投入", data: sortedData });
}
}
} catch (error) {
console.log(error)
console.log(error);
}
}
};
// 单次制裁-影响分析-企业市值变化
const marketValueData = ref([]);
......@@ -343,8 +421,8 @@ const getMarketValue = async () => {
if (!selectedCompanyId.value) return;
try {
const res = await getSingleSanctionEntityMarketValue({
id: selectedCompanyId.value,
})
id: selectedCompanyId.value
});
if (res.code === 200) {
marketValueData.value = res.data || [];
......@@ -363,14 +441,13 @@ const getMarketValue = async () => {
nextTick(() => {
initMarketChart();
});
marketChart.interpret({ type: "折线图", name: "企业市值变化", data: sortedData });
}
}
} catch (error) {
console.log(error)
console.log(error);
}
}
};
// 单次制裁-影响分析-企业规模-人员
const personnelData = ref([]);
......@@ -379,8 +456,8 @@ const getPersonnel = async () => {
if (!selectedCompanyId.value) return;
try {
const res = await getSingleSanctionEntityPersonnel({
id: selectedCompanyId.value,
})
id: selectedCompanyId.value
});
if (res.code === 200) {
personnelData.value = res.data || [];
......@@ -397,13 +474,12 @@ const getPersonnel = async () => {
nextTick(() => {
initRevenueChart();
});
revenueChart.interpret({ type: "折线图", name: "企业规模", data: sortedData });
}
} catch (error) {
console.log(error)
console.log(error);
}
}
};
// 单次制裁-影响分析-企业规模-净利润
const netProfitData = ref([]);
......@@ -412,8 +488,8 @@ const getNetProfitData = async () => {
if (!selectedCompanyId.value) return;
try {
const res = await getSingleSanctionEntityNetProfit({
id: selectedCompanyId.value,
})
id: selectedCompanyId.value
});
if (res.code === 200) {
netProfitData.value = res.data || [];
......@@ -430,14 +506,12 @@ const getNetProfitData = async () => {
nextTick(() => {
initRevenueChart();
});
revenueChart.interpret({ type: "折线图", name: "企业规模", data: sortedData });
}
} catch (error) {
console.log(error)
console.log(error);
}
}
};
// 单次制裁-影响分析-制裁企业列表
const entityList = ref([]);
......@@ -453,8 +527,8 @@ const getRevenueData = async () => {
if (!selectedCompanyId.value) return;
try {
const res = await getSingleSanctionEntityRevenue({
id: selectedCompanyId.value,
})
id: selectedCompanyId.value
});
if (res.code === 200) {
revenueData.value = res.data || [];
// 将数据格式化为图表所需格式
......@@ -469,31 +543,32 @@ const getRevenueData = async () => {
nextTick(() => {
initRevenueChart();
});
revenueChart.interpret({ type: "折线图", name: "企业规模", data: sortedData });
}
} catch (error) {
console.log(error)
console.log(error);
}
}
};
const handleScaleClick = (item) => {
const handleScaleClick = item => {
activeScale.value = item;
if (item === '营收') {
if (item === "营收") {
getRevenueData();
} else if (item === '净利润') {
} else if (item === "净利润") {
getNetProfitData();
} else if (item === '人员') {
} else if (item === "人员") {
getPersonnel();
}
}
};
watch(selectedCompanyId, (val) => {
watch(selectedCompanyId, val => {
if (val) {
// 切换企业时,根据当前激活的按钮更新企业规模数据
if (activeScale.value === '营收') {
if (activeScale.value === "营收") {
getRevenueData();
} else if (activeScale.value === '净利润') {
} else if (activeScale.value === "净利润") {
getNetProfitData();
} else if (activeScale.value === '人员') {
} else if (activeScale.value === "人员") {
getPersonnel();
}
// 更新其他图表数据
......@@ -510,15 +585,17 @@ const getEntityList = async () => {
sanRecordId: sanRecordId.value,
isOnlyCn: false,
domainId: searchDomain.value || undefined,
searchText: searchKeyword.value || undefined,
})
searchText: searchKeyword.value || undefined
});
if (res.code === 200) {
entityList.value = (res.data || []).reduce((acc, group) => {
if (group.orgType === "企业" && group.orgInfoList) {
acc.push(...group.orgInfoList.map(org => ({
id: org.id,
name: org.orgNameZh
})));
acc.push(
...group.orgInfoList.map(org => ({
id: org.id,
name: org.orgNameZh
}))
);
}
return acc;
}, []);
......@@ -529,9 +606,9 @@ const getEntityList = async () => {
}
}
} catch (error) {
console.log(error)
console.log(error);
}
}
};
const debouncedGetEntityList = debounce(() => {
getEntityList();
......@@ -547,8 +624,8 @@ watch(searchKeyword, () => {
const getUrlParams = () => {
const urlParams = new URLSearchParams(window.location.search);
sanRecordId.value = urlParams.get("id") || ""
}
sanRecordId.value = urlParams.get("id") || "";
};
const activeScale = ref("营收");
const scaleOptions = ["营收", "净利润", "人员"];
......@@ -584,7 +661,7 @@ const shareChartData = ref({
dates: [],
values: [],
sanctionDate: "",
unit: "%",
unit: "%"
});
const chartRef = ref(null);
......@@ -593,7 +670,7 @@ const rdChartRef = ref(null);
const shareChartRef = ref(null);
// 提取通用配置生成器
const getBaseOption = (data) => {
const getBaseOption = data => {
return {
tooltip: {
trigger: "axis",
......@@ -609,7 +686,7 @@ const getBaseOption = (data) => {
xAxis: {
type: "category",
boundaryGap: true,
data: data.dates,
data: data.dates || [],
axisLine: { lineStyle: { color: "#E6EBF5" } },
axisLabel: {
color: "#606266",
......@@ -627,20 +704,20 @@ const getBaseOption = (data) => {
axisLabel: {
color: "#606266",
fontSize: 12,
formatter: (value) => value.toLocaleString()
formatter: value => value.toLocaleString()
},
splitLine: { lineStyle: { type: "dashed", color: "rgba(231, 243, 255, 1)" } }
}
};
};
const revenueChartOption = ref(getBaseOption({}));
// 1. 初始化企业营收图表 (应用原型图置顶样式)
const initRevenueChart = () => {
const dom = chartRef.value;
// const dom = chartRef.value;
const data = chartData.value;
if (!dom || !data.values || data.values.length === 0) return;
// if (!dom || !data.values || data.values.length === 0) return;
let myChart = echarts.getInstanceByDom(dom) || echarts.init(dom);
// let myChart = echarts.getInstanceByDom(dom) || echarts.init(dom);
const lastDate = data.dates[data.dates.length - 1];
const lastValue = data.values[data.values.length - 1];
const unit = data.unit || " 亿元";
......@@ -651,64 +728,70 @@ const initRevenueChart = () => {
const option = getBaseOption(data);
option.grid.top = "20%"; // 增加顶部空间
option.yAxis.max = maxY;
option.series = [{
data: data.values,
type: "line",
symbol: "none",
lineStyle: { color: "#055FC2", width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "rgba(5, 95, 194, 0.2)" },
{ offset: 1, color: "rgba(5, 95, 194, 0)" }
])
},
markLine: {
option.series = [
{
data: data.values,
type: "line",
symbol: "none",
data: [
data.sanctionDate ? {
xAxis: data.sanctionDate,
label: {
formatter: "列入实体清单",
position: "end",
color: "#F56C6C",
backgroundColor: "rgba(255, 238, 238, 1)",
borderRadius: 4,
padding: [4, 8]
},
lineStyle: { color: "#F56C6C", type: "dotted", width: 1 }
} : null,
[
{ coord: [lastDate, lastValue], symbol: "none" },
{ coord: [lastDate, maxY], symbol: "none", lineStyle: { color: "#055FC2", type: "dotted" } }
]
].filter(Boolean)
},
markPoint: {
symbol: "circle",
symbolSize: 6,
itemStyle: { color: "#055FC2" },
label: {
show: true,
position: "left",
formatter: "{c}" + unit.trim(),
color: "#055FC2",
fontSize: 16,
fontWeight: "bold",
offset: [-10, 0]
lineStyle: { color: "#055FC2", width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "rgba(5, 95, 194, 0.2)" },
{ offset: 1, color: "rgba(5, 95, 194, 0)" }
])
},
data: [{ coord: [lastDate, maxY], value: data.endValue || lastValue }]
markLine: {
symbol: "none",
data: [
data.sanctionDate
? {
xAxis: data.sanctionDate,
label: {
formatter: "列入实体清单",
position: "end",
color: "#F56C6C",
backgroundColor: "rgba(255, 238, 238, 1)",
borderRadius: 4,
padding: [4, 8]
},
lineStyle: { color: "#F56C6C", type: "dotted", width: 1 }
}
: null,
[
{ coord: [lastDate, lastValue], symbol: "none" },
{ coord: [lastDate, maxY], symbol: "none", lineStyle: { color: "#055FC2", type: "dotted" } }
]
].filter(Boolean)
},
markPoint: {
symbol: "circle",
symbolSize: 6,
itemStyle: { color: "#055FC2" },
label: {
show: true,
position: "left",
formatter: "{c}" + unit.trim(),
color: "#055FC2",
fontSize: 16,
fontWeight: "bold",
offset: [-10, 0]
},
data: [{ coord: [lastDate, maxY], value: data.endValue || lastValue }]
}
}
}];
myChart.setOption(option, true);
];
// myChart.setOption(option, true);
revenueChartOption.value = option;
};
const marketChartOption = ref(getBaseOption({}));
// 2. 初始化企业市值图表
const initMarketChart = () => {
const dom = marketChartRef.value;
// const dom = marketChartRef.value;
const data = marketChartData.value;
if (!dom || !data.values || data.values.length === 0) return;
// if (!dom || !data.values || data.values.length === 0) return;
let myChart = echarts.getInstanceByDom(dom) || echarts.init(dom);
// let myChart = echarts.getInstanceByDom(dom) || echarts.init(dom);
const lastDate = data.dates[data.dates.length - 1];
const lastValue = data.values[data.values.length - 1];
const unit = data.unit || " 万";
......@@ -719,57 +802,63 @@ const initMarketChart = () => {
const option = getBaseOption(data);
option.grid.top = "20%"; // 增加顶部空间
option.yAxis.max = maxY;
option.series = [{
data: data.values,
type: "line",
symbol: "none",
lineStyle: { color: "#055FC2", width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "rgba(5, 95, 194, 0.2)" },
{ offset: 1, color: "rgba(5, 95, 194, 0)" }
])
},
markLine: {
option.series = [
{
data: data.values,
type: "line",
symbol: "none",
data: [
data.sanctionDate ? {
xAxis: data.sanctionDate,
label: { formatter: "列入实体清单", position: "end", color: "#F56C6C" },
lineStyle: { color: "#F56C6C", type: "dotted" }
} : null,
[
{ coord: [lastDate, lastValue], symbol: "none" },
{ coord: [lastDate, maxY], symbol: "none", lineStyle: { color: "#055FC2", type: "dotted" } }
]
].filter(Boolean)
},
markPoint: {
symbol: "circle",
symbolSize: 6,
itemStyle: { color: "#055FC2" },
label: {
show: true,
position: "left",
formatter: "{c}" + unit.trim(),
color: "#055FC2",
fontSize: 16,
fontWeight: "bold",
offset: [-10, 0]
lineStyle: { color: "#055FC2", width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "rgba(5, 95, 194, 0.2)" },
{ offset: 1, color: "rgba(5, 95, 194, 0)" }
])
},
markLine: {
symbol: "none",
data: [
data.sanctionDate
? {
xAxis: data.sanctionDate,
label: { formatter: "列入实体清单", position: "end", color: "#F56C6C" },
lineStyle: { color: "#F56C6C", type: "dotted" }
}
: null,
[
{ coord: [lastDate, lastValue], symbol: "none" },
{ coord: [lastDate, maxY], symbol: "none", lineStyle: { color: "#055FC2", type: "dotted" } }
]
].filter(Boolean)
},
data: [{ coord: [lastDate, maxY], value: data.endValue || lastValue }]
markPoint: {
symbol: "circle",
symbolSize: 6,
itemStyle: { color: "#055FC2" },
label: {
show: true,
position: "left",
formatter: "{c}" + unit.trim(),
color: "#055FC2",
fontSize: 16,
fontWeight: "bold",
offset: [-10, 0]
},
data: [{ coord: [lastDate, maxY], value: data.endValue || lastValue }]
}
}
}];
myChart.setOption(option, true);
];
// myChart.setOption(option, true);
marketChartOption.value = option;
};
const rdChartOption = ref(getBaseOption({}));
// 3. 初始化研发投入图表
const initRDChart = () => {
const dom = rdChartRef.value;
// const dom = rdChartRef.value;
const data = rdChartData.value;
if (!dom || !data.values || data.values.length === 0) return;
// if (!dom || !data.values || data.values.length === 0) return;
let myChart = echarts.getInstanceByDom(dom) || echarts.init(dom);
// let myChart = echarts.getInstanceByDom(dom) || echarts.init(dom);
const lastDate = data.dates[data.dates.length - 1];
const lastValue = data.values[data.values.length - 1];
const unit = data.unit || " 万";
......@@ -781,62 +870,67 @@ const initRDChart = () => {
const option = getBaseOption(data);
option.grid.top = "20%"; // 增加顶部空间
option.yAxis.max = maxY;
option.series = [{
data: data.values,
type: "line",
symbol: "none",
lineStyle: { color: "#055FC2", width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "rgba(5, 95, 194, 0.2)" },
{ offset: 1, color: "rgba(5, 95, 194, 0)" }
])
},
markLine: {
option.series = [
{
data: data.values,
type: "line",
symbol: "none",
data: [
data.sanctionDate ? {
xAxis: data.sanctionDate,
label: { formatter: "列入实体清单", position: "end", color: "#F56C6C" },
lineStyle: { color: "#F56C6C", type: "dotted" }
} : null,
[
{ coord: [lastDate, lastValue], symbol: "none" },
{ coord: [lastDate, maxY], symbol: "none", lineStyle: { color: "#055FC2", type: "dotted" } }
]
].filter(Boolean)
},
markPoint: {
symbol: "circle",
symbolSize: 6,
itemStyle: { color: "#055FC2" },
label: {
show: true,
position: "left",
formatter: "{c}" + unit.trim(),
color: "#055FC2",
fontSize: 16,
fontWeight: "bold",
offset: [-10, 0]
lineStyle: { color: "#055FC2", width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "rgba(5, 95, 194, 0.2)" },
{ offset: 1, color: "rgba(5, 95, 194, 0)" }
])
},
data: [{ coord: [lastDate, maxY], value: data.endValue || lastValue }]
markLine: {
symbol: "none",
data: [
data.sanctionDate
? {
xAxis: data.sanctionDate,
label: { formatter: "列入实体清单", position: "end", color: "#F56C6C" },
lineStyle: { color: "#F56C6C", type: "dotted" }
}
: null,
[
{ coord: [lastDate, lastValue], symbol: "none" },
{ coord: [lastDate, maxY], symbol: "none", lineStyle: { color: "#055FC2", type: "dotted" } }
]
].filter(Boolean)
},
markPoint: {
symbol: "circle",
symbolSize: 6,
itemStyle: { color: "#055FC2" },
label: {
show: true,
position: "left",
formatter: "{c}" + unit.trim(),
color: "#055FC2",
fontSize: 16,
fontWeight: "bold",
offset: [-10, 0]
},
data: [{ coord: [lastDate, maxY], value: data.endValue || lastValue }]
}
}
}];
myChart.setOption(option, true);
];
// myChart.setOption(option, true);
rdChartOption.value = option;
};
const shareChartOption = ref(getBaseOption({}));
// 4. 初始化市场占比图表 (柱状图)
const initShareChart = () => {
const dom = shareChartRef.value;
// const dom = shareChartRef.value;
const data = shareChartData.value;
if (!dom || !data.values || data.values.length === 0) return;
// if (!dom || !data.values || data.values.length === 0) return;
let myChart = echarts.getInstanceByDom(dom) || echarts.init(dom);
// let myChart = echarts.getInstanceByDom(dom) || echarts.init(dom);
const option = getBaseOption(data);
// 针对柱状图优化 X 轴
option.xAxis.boundaryGap = true;
option.xAxis.axisLabel.interval = 'auto'; // 自动计算间隔,避免标签重叠导致柱子变细
option.xAxis.axisLabel.interval = "auto"; // 自动计算间隔,避免标签重叠导致柱子变细
option.yAxis.name = "百分比";
// 动态计算 Y 轴最大值,避免数据太小时展示不明显
......@@ -844,38 +938,43 @@ const initShareChart = () => {
const maxY = maxVal === 0 ? 100 : maxVal * 1.5;
option.yAxis.max = maxY;
option.series = [{
data: data.values,
type: "bar",
// 移除固定 barWidth,让其根据数据量自适应,或设置一个合理的最小值
barMaxWidth: 20,
barMinHeight: 2, // 确保即使数值极小也能看到一点
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "#055FC2" },
{ offset: 1, color: "rgba(5, 95, 194, 0.1)" }
]),
borderRadius: [8, 8, 0, 0]
},
markLine: {
symbol: "none",
data: [
data.sanctionDate ? {
xAxis: data.sanctionDate,
label: {
formatter: "列入实体清单",
position: "end",
color: "#F56C6C",
backgroundColor: "rgba(245, 108, 108, 0.1)",
padding: [2, 4],
borderRadius: 10
},
lineStyle: { color: "#F56C6C", type: "dotted" }
} : null
].filter(Boolean)
option.series = [
{
data: data.values,
type: "bar",
// 移除固定 barWidth,让其根据数据量自适应,或设置一个合理的最小值
barMaxWidth: 20,
barMinHeight: 2, // 确保即使数值极小也能看到一点
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "#055FC2" },
{ offset: 1, color: "rgba(5, 95, 194, 0.1)" }
]),
borderRadius: [8, 8, 0, 0]
},
markLine: {
symbol: "none",
data: [
data.sanctionDate
? {
xAxis: data.sanctionDate,
label: {
formatter: "列入实体清单",
position: "end",
color: "#F56C6C",
backgroundColor: "rgba(245, 108, 108, 0.1)",
padding: [2, 4],
borderRadius: 10
},
lineStyle: { color: "#F56C6C", type: "dotted" }
}
: null
].filter(Boolean)
}
}
}];
myChart.setOption(option, true);
];
// myChart.setOption(option, true);
shareChartOption.value = option;
};
onMounted(async () => {
......@@ -901,7 +1000,6 @@ onMounted(async () => {
padding-top: 16px;
padding-bottom: 50px;
.toggle-btns {
display: flex;
gap: 8px;
......@@ -1214,4 +1312,50 @@ onMounted(async () => {
}
}
}
.data-origin-box {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 30px 0;
.data-origin-icon {
width: 16px;
height: 16px;
font-size: 0px;
margin-right: 8px;
img {
width: 100%;
height: 100%;
}
}
.data-origin-text {
font-family: Source Han Sans CN;
font-size: 14px;
color: var(--text-primary-50-color);
}
}
.ai-pane {
position: absolute;
right: 0px;
bottom: 15px;
z-index: 2;
:deep(.ai-pane-wrapper) {
display: none;
}
:deep(.ai-button-wrapper) {
display: flex;
}
&:hover {
width: 100%;
bottom: 0px;
:deep(.ai-pane-wrapper) {
display: block;
}
:deep(.ai-button-wrapper) {
display: none;
}
}
}
</style>
......@@ -12,10 +12,19 @@
<div class="company-list-container">
<div class="list-header">科研机构名称</div>
<div class="company-list">
<el-empty v-if="entityList.length === 0" description="暂无数据" style="padding-top: 200px"
:image-size="100" />
<div class="company-item" :class="{ active: selectedCompanyId === item.id }" v-for="item in entityList"
:key="item.id" @click="selectedCompanyId = item.id">
<el-empty
v-if="entityList.length === 0"
description="暂无数据"
style="padding-top: 200px"
:image-size="100"
/>
<div
class="company-item"
:class="{ active: selectedCompanyId === item.id }"
v-for="item in entityList"
:key="item.id"
@click="selectedCompanyId = item.id"
>
<div class="icon-wrapper">
<img :src="defaultTitle" alt="" />
</div>
......@@ -30,13 +39,28 @@
<div class="right-item">
<AnalysisBox title="科研仪器对美依赖情况">
<div class="right-main">
<el-empty v-if="rdInstrumentDependency.length === 0" description="暂无数据" style="padding-top: 100px"
:image-size="100" />
<div v-if="rdInstrumentDependency.length !== 0" class="echarts" ref="chartRef"></div>
<el-empty
v-if="rdInstrumentDependency.length === 0"
description="暂无数据"
style="padding-top: 100px"
:image-size="100"
/>
<!-- <div v-if="rdInstrumentDependency.length !== 0" class="echarts" ref="chartRef"></div>
<div v-if="rdInstrumentDependency.length !== 0" class="bottom">
<img :src="ai" class="ai-icon" alt="" />
<span class="text">受制裁实体中,电子测量仪器对美依赖程度最高,此外,电子策略仪器、物理性能测试仪器等也有较高的对美依赖度。</span>
<img :src="right" class="right-icon" alt="" />
</div> -->
<EChart :option="rdInstrumentDependencyOption" autoresize :style="{ height: '300px' }" />
<div class="data-origin-box">
<div class="data-origin-icon">
<img :src="tipsIcon" alt="" />
</div>
<div class="data-origin-text">科研仪器对美依赖情况,数据来源:美国各行政机构官网</div>
</div>
<div class="ai-pane">
<AiButton />
<AiPane :aiContent="rdInstrumentDependencyChart.interpretation" />
</div>
</div>
</AnalysisBox>
......@@ -44,13 +68,35 @@
<div class="right-item">
<AnalysisBox title="科研仪器进口国分布">
<div class="right-main">
<el-empty v-if="rdInstrumentImportCountry.length === 0" description="暂无数据" style="padding-top: 100px"
:image-size="100" />
<div v-if="rdInstrumentImportCountry.length !== 0" class="echarts" ref="marketChartRef"></div>
<el-empty
v-if="rdInstrumentImportCountry.length === 0"
description="暂无数据"
style="padding-top: 100px"
:image-size="100"
/>
<!-- <div v-if="rdInstrumentImportCountry.length !== 0" class="echarts" ref="marketChartRef"></div>
<div v-if="rdInstrumentImportCountry.length !== 0" class="bottom">
<img :src="ai" class="ai-icon" alt="" />
<span class="text">电子测量仪器来源国主要为美国,但是德国、日本等国也占有较大比重,后续可考虑将上述国家作为仪器进口来源替代国。</span>
<span class="text"
>电子测量仪器来源国主要为美国,但是德国、日本等国也占有较大比重,后续可考虑将上述国家作为仪器进口来源替代国。</span
>
<img :src="right" class="right-icon" alt="" />
</div> -->
<EChart
v-if="rdInstrumentImportCountry.length !== 0"
:option="rdInstrumentImportCountryOption"
autoresize
:style="{ height: '300px' }"
/>
<div v-if="rdInstrumentImportCountry.length !== 0" class="data-origin-box">
<div class="data-origin-icon">
<img :src="tipsIcon" alt="" />
</div>
<div class="data-origin-text">科研仪器进口国分布,数据来源:美国各行政机构官网</div>
</div>
<div class="ai-pane">
<AiButton />
<AiPane :aiContent="rdInstrumentImportCountryChart.interpretation" />
</div>
</div>
</AnalysisBox>
......@@ -84,18 +130,42 @@
<AnalysisBox title="新增国际合作项目">
<template #header-btn>
<el-select v-model="activeProjectDomain" class="project-domain-select" placeholder="全部领域">
<el-option v-for="item in projectDomainOptions" :key="item.value" :label="item.label"
:value="item.value" />
<el-option
v-for="item in projectDomainOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</template>
<div class="right-main">
<el-empty v-if="internationalCooperation.length === 0" description="暂无数据" style="padding-top: 100px"
:image-size="100" />
<div v-if="internationalCooperation.length !== 0" class="echarts" ref="rdChartRef"></div>
<el-empty
v-if="internationalCooperation.length === 0"
description="暂无数据"
style="padding-top: 100px"
:image-size="100"
/>
<!-- <div v-if="internationalCooperation.length !== 0" class="echarts" ref="rdChartRef"></div>
<div v-if="internationalCooperation.length !== 0" class="bottom">
<img :src="ai" class="ai-icon" alt="" />
<span class="text">列入实体清单后新增国际项目数量明显减少。</span>
<img :src="right" class="right-icon" alt="" />
</div> -->
<EChart
v-if="internationalCooperation.length !== 0"
:option="internationalCooperationOption"
autoresize
:style="{ height: '300px' }"
/>
<div v-if="internationalCooperation.length !== 0" class="data-origin-box">
<div class="data-origin-icon">
<img :src="tipsIcon" alt="" />
</div>
<div class="data-origin-text">新增国际合作项目,数据来源:美国各行政机构官网</div>
</div>
<div class="ai-pane">
<AiButton />
<AiPane :aiContent="internationalCooperationChart.interpretation" />
</div>
</div>
</AnalysisBox>
......@@ -129,18 +199,42 @@
<AnalysisBox title="新增国际合著论文">
<template #header-btn>
<el-select v-model="activePaperDomain" class="paper-domain-select" placeholder="全部领域">
<el-option v-for="item in paperDomainOptions" :key="item.value" :label="item.label"
:value="item.value" />
<el-option
v-for="item in paperDomainOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</template>
<div class="right-main">
<el-empty v-if="internationalPaper.length === 0" description="暂无数据" style="padding-top: 100px"
:image-size="100" />
<div v-if="internationalPaper.length !== 0" class="echarts" ref="shareChartRef"></div>
<el-empty
v-if="internationalPaper.length === 0"
description="暂无数据"
style="padding-top: 100px"
:image-size="100"
/>
<!-- <div v-if="internationalPaper.length !== 0" class="echarts" ref="shareChartRef"></div>
<div v-if="internationalPaper.length !== 0" class="bottom">
<img :src="ai" class="ai-icon" alt="" />
<span class="text">总量下降,特别是在人工智能、高端通信等敏感技术领域。</span>
<img :src="right" class="right-icon" alt="" />
</div> -->
<EChart
v-if="internationalPaper.length !== 0"
:option="internationalPaperOption"
autoresize
:style="{ height: '300px' }"
/>
<div v-if="internationalPaper.length !== 0" class="data-origin-box">
<div class="data-origin-icon">
<img :src="tipsIcon" alt="" />
</div>
<div class="data-origin-text">新增国际合著论文,数据来源:美国各行政机构官网</div>
</div>
<div class="ai-pane">
<AiButton />
<AiPane :aiContent="internationalPaperChart.interpretation" />
</div>
</div>
</AnalysisBox>
......@@ -163,6 +257,15 @@ import {
getSingleSanctionEntityInternationalCooperation,
getSingleSanctionEntityInternationalPaper
} from "@/api/exportControlV2.0";
import tipsIcon from "../../../../../assets/icons/info-icon.png";
import AiButton from "@/components/base/Ai/AiButton/index.vue";
import AiPane from "@/components/base/Ai/AiPane/index.vue";
import EChart from "@/components/Chart/index.vue";
import { useChartInterpretation } from "@/views/exportControl/utils/common";
const rdInstrumentDependencyChart = useChartInterpretation();
const rdInstrumentImportCountryChart = useChartInterpretation();
const internationalCooperationChart = useChartInterpretation();
const internationalPaperChart = useChartInterpretation();
const domainOptions = [
{ label: "全部领域", value: "" },
......@@ -216,6 +319,7 @@ const getInternationalPaper = async () => {
nextTick(() => {
initShareChart();
});
internationalPaperChart.interpret({ type: "折线图", name: "新增国际合著论文", data: res.data });
}
} catch (error) {
console.log(error);
......@@ -238,6 +342,7 @@ const getInternationalCooperation = async () => {
nextTick(() => {
initRdChart();
});
internationalCooperationChart.interpret({ type: "折线图", name: "新增国际合作项目", data: res.data });
}
} catch (error) {
console.log(error);
......@@ -255,6 +360,7 @@ const getRDInstrumentImportCountry = async () => {
nextTick(() => {
initMarketChart();
});
rdInstrumentImportCountryChart.interpret({ type: "饼图", name: "科研仪器进口国分布", data: res.data });
}
} catch (error) {
console.log(error);
......@@ -272,6 +378,7 @@ const getRDInstrumentDependency = async () => {
nextTick(() => {
initChart();
});
rdInstrumentDependencyChart.interpret({ type: "柱状图", name: "科研仪器对美依赖情况", data: res.data });
}
} catch (error) {
console.log(error);
......@@ -340,9 +447,72 @@ const marketChartRef = ref(null);
const rdChartRef = ref(null);
const shareChartRef = ref(null);
const rdInstrumentDependencyOption = ref({
grid: {
top: "5%",
left: "0%",
right: "5%",
bottom: "0%",
containLabel: true
},
dataZoom: [
{
type: "inside",
orient: "vertical",
start: 0,
end: 100,
zoomLock: true
}
],
xAxis: {
show: false,
type: "value"
},
yAxis: [
{
type: "category",
data: [],
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
color: "rgb(59, 65, 75)",
fontSize: 16,
fontWeight: 400,
fontFamily: "Microsoft YaHei",
margin: 20,
interval: 0
},
inverse: true
},
{
type: "category",
data: [],
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
margin: 20,
interval: 0,
fontSize: 16,
fontFamily: "Microsoft YaHei",
color: (value, index) => {
return index < 3 ? "rgba(209, 90, 90, 1)" : "rgba(59, 139, 245, 1)";
}
},
inverse: true
}
],
series: [
{
type: "bar",
data: [],
barWidth: 10,
showBackground: false
}
]
});
const initChart = () => {
if (!chartRef.value || rdInstrumentDependency.value.length === 0) return;
const myChart = echarts.getInstanceByDom(chartRef.value) || echarts.init(chartRef.value);
// if (!chartRef.value || rdInstrumentDependency.value.length === 0) return;
// const myChart = echarts.getInstanceByDom(chartRef.value) || echarts.init(chartRef.value);
// 处理接口数据,映射为图表所需的 name 和 value
const chartData = rdInstrumentDependency.value.map(item => ({
......@@ -350,95 +520,181 @@ const initChart = () => {
value: item.count
}));
const option = {
grid: {
top: "5%",
left: "0%",
right: "5%",
bottom: "0%",
containLabel: true
},
dataZoom: [
{
type: "inside",
orient: "vertical",
start: 0,
end: chartData.length > 7 ? (7 / chartData.length) * 100 : 100,
zoomLock: true
// const option = {
// grid: {
// top: "5%",
// left: "0%",
// right: "5%",
// bottom: "0%",
// containLabel: true
// },
// dataZoom: [
// {
// type: "inside",
// orient: "vertical",
// start: 0,
// end: chartData.length > 7 ? (7 / chartData.length) * 100 : 100,
// zoomLock: true
// }
// ],
// xAxis: {
// show: false,
// type: "value"
// },
// yAxis: [
// {
// type: "category",
// data: chartData.map(item => item.name),
// axisLine: { show: false },
// axisTick: { show: false },
// axisLabel: {
// color: "rgb(59, 65, 75)",
// fontSize: 16,
// fontWeight: 400,
// fontFamily: "Microsoft YaHei",
// margin: 20,
// interval: 0
// },
// inverse: true
// },
// {
// type: "category",
// data: chartData.map(item => item.value),
// axisLine: { show: false },
// axisTick: { show: false },
// axisLabel: {
// margin: 20,
// interval: 0,
// fontSize: 16,
// fontFamily: "Microsoft YaHei",
// color: (value, index) => {
// return index < 3 ? "rgba(209, 90, 90, 1)" : "rgba(59, 139, 245, 1)";
// }
// },
// inverse: true
// }
// ],
// series: [
// {
// type: "bar",
// data: chartData.map((item, index) => {
// return {
// value: item.value,
// itemStyle: {
// color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
// {
// offset: 0,
// color: index < 3 ? "rgba(209, 90, 90, 0.05)" : "rgba(59, 139, 245, 0.05)"
// },
// {
// offset: 1,
// color: index < 3 ? "rgba(209, 90, 90, 1)" : "rgba(59, 139, 245, 1)"
// }
// ]),
// borderRadius: [0, 10, 10, 0]
// },
// label: {
// show: false
// }
// };
// }),
// barWidth: 10,
// showBackground: false
// }
// ]
// };
// myChart.setOption(option);
// rdInstrumentDependencyOption.value = option;
rdInstrumentDependencyOption.value.dataZoom[0].end = chartData.length > 7 ? (7 / chartData.length) * 100 : 100;
rdInstrumentDependencyOption.value.yAxis[0].data = chartData.map(item => item.name);
rdInstrumentDependencyOption.value.yAxis[1].data = chartData.map(item => item.value);
rdInstrumentDependencyOption.value.series[0].data = chartData.map((item, index) => {
return {
value: item.value,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{
offset: 0,
color: index < 3 ? "rgba(209, 90, 90, 0.05)" : "rgba(59, 139, 245, 0.05)"
},
{
offset: 1,
color: index < 3 ? "rgba(209, 90, 90, 1)" : "rgba(59, 139, 245, 1)"
}
]),
borderRadius: [0, 10, 10, 0]
},
label: {
show: false
}
],
xAxis: {
show: false,
type: "value"
},
yAxis: [
{
type: "category",
data: chartData.map(item => item.name),
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
color: "rgb(59, 65, 75)",
};
});
};
const colors = ["#66b1ff", "#ffba63", "#7ce3d3", "#8a9dff", "#ff7c7c", "#b386f2", "#4080ff"];
const rdInstrumentImportCountryOption = ref({
color: colors,
tooltip: {
trigger: "item",
formatter: "{b}: {c}%"
},
legend: {
type: "scroll",
orient: "vertical",
right: "5%",
top: "middle",
bottom: "20",
itemGap: 20,
icon: "circle",
// formatter: function (name) {
// const item = chartData.find(i => i.name === name);
// const total = chartData.reduce((sum, i) => sum + i.value, 0);
// const percent = total > 0 ? ((item.value / total) * 100).toFixed(0) : 0;
// return `{name|${name}} {value|${percent}%}`;
// },
textStyle: {
rich: {
name: {
fontSize: 16,
fontWeight: 400,
fontFamily: "Microsoft YaHei",
margin: 20,
interval: 0
color: "#333",
width: 60,
padding: [0, 0, 0, 5],
fontFamily: "Microsoft YaHei"
},
inverse: true
},
{
type: "category",
data: chartData.map(item => item.value),
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
margin: 20,
interval: 0,
value: {
fontSize: 16,
fontFamily: "Microsoft YaHei",
color: (value, index) => {
return index < 3 ? "rgba(209, 90, 90, 1)" : "rgba(59, 139, 245, 1)";
}
},
inverse: true
}
],
series: [
{
type: "bar",
data: chartData.map((item, index) => {
return {
value: item.value,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{
offset: 0,
color: index < 3 ? "rgba(209, 90, 90, 0.05)" : "rgba(59, 139, 245, 0.05)"
},
{
offset: 1,
color: index < 3 ? "rgba(209, 90, 90, 1)" : "rgba(59, 139, 245, 1)"
}
]),
borderRadius: [0, 10, 10, 0]
},
label: {
show: false
}
};
}),
barWidth: 10,
showBackground: false
color: "#333",
fontFamily: "Microsoft YaHei"
}
}
]
};
myChart.setOption(option);
};
}
},
series: [
{
name: "科研仪器进口国分布",
type: "pie",
radius: ["80px", "98px"],
center: ["30%", "50%"],
avoidLabelOverlap: false,
label: {
show: false,
position: "center"
},
emphasis: {
label: {
show: false
}
},
labelLine: {
show: false
},
data: []
}
]
});
const initMarketChart = () => {
if (!marketChartRef.value || rdInstrumentImportCountry.value.length === 0) return;
const myChart = echarts.getInstanceByDom(marketChartRef.value) || echarts.init(marketChartRef.value);
// if (!marketChartRef.value || rdInstrumentImportCountry.value.length === 0) return;
// const myChart = echarts.getInstanceByDom(marketChartRef.value) || echarts.init(marketChartRef.value);
const chartData = rdInstrumentImportCountry.value.map(item => ({
name: item.name,
......@@ -507,12 +763,84 @@ const initMarketChart = () => {
}
]
};
myChart.setOption(option);
// myChart.setOption(option);
rdInstrumentImportCountryOption.value = option;
};
const internationalCooperationOption = ref({
tooltip: {
trigger: "axis"
},
grid: {
top: "15%",
left: "3%",
right: "4%",
bottom: "3%",
containLabel: true
},
xAxis: {
type: "category",
boundaryGap: false,
data: [],
axisLine: {
lineStyle: {
color: "#E6EBF5"
}
},
axisLabel: {
color: "#606266",
fontSize: 12,
lineHeight: 18,
fontFamily: "Microsoft YaHei"
},
axisTick: {
show: false
}
},
yAxis: {
type: "value",
min: 0,
max: 5,
interval: 10 / 5,
axisLabel: {
color: "#606266",
fontSize: 12,
fontFamily: "Microsoft YaHei"
},
splitLine: {
lineStyle: {
type: "dashed",
color: "rgba(231, 243, 255, 1)"
}
}
},
series: [
{
data: [],
type: "line",
symbol: "none",
smooth: false,
lineStyle: {
color: "#055FC2",
width: 2
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: "rgba(5, 95, 194, 0.2)"
},
{
offset: 1,
color: "rgba(5, 95, 194, 0)"
}
])
}
}
]
});
const initRdChart = () => {
if (!rdChartRef.value) return;
const myChart = echarts.getInstanceByDom(rdChartRef.value) || echarts.init(rdChartRef.value);
// if (!rdChartRef.value) return;
// const myChart = echarts.getInstanceByDom(rdChartRef.value) || echarts.init(rdChartRef.value);
// 处理接口返回的数据,按年份升序排序
const chartData = [...internationalCooperation.value].sort((a, b) => a.year - b.year);
......@@ -523,84 +851,159 @@ const initRdChart = () => {
const maxVal = Math.max(...data, 10);
const yMax = Math.ceil(maxVal / 5) * 5;
const option = {
tooltip: {
trigger: "axis"
// const option = {
// tooltip: {
// trigger: "axis"
// },
// grid: {
// top: "15%",
// left: "3%",
// right: "4%",
// bottom: "3%",
// containLabel: true
// },
// xAxis: {
// type: "category",
// boundaryGap: false,
// data: years,
// axisLine: {
// lineStyle: {
// color: "#E6EBF5"
// }
// },
// axisLabel: {
// color: "#606266",
// fontSize: 12,
// lineHeight: 18,
// fontFamily: "Microsoft YaHei"
// },
// axisTick: {
// show: false
// }
// },
// yAxis: {
// type: "value",
// min: 0,
// max: yMax,
// interval: yMax / 5,
// axisLabel: {
// color: "#606266",
// fontSize: 12,
// fontFamily: "Microsoft YaHei"
// },
// splitLine: {
// lineStyle: {
// type: "dashed",
// color: "rgba(231, 243, 255, 1)"
// }
// }
// },
// series: [
// {
// data: data,
// type: "line",
// symbol: "none",
// smooth: false,
// lineStyle: {
// color: "#055FC2",
// width: 2
// },
// areaStyle: {
// color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
// {
// offset: 0,
// color: "rgba(5, 95, 194, 0.2)"
// },
// {
// offset: 1,
// color: "rgba(5, 95, 194, 0)"
// }
// ])
// }
// }
// ]
// };
// myChart.setOption(option);
internationalCooperationOption.value.xAxis.data = years;
internationalCooperationOption.value.yAxis.max = yMax;
internationalCooperationOption.value.yAxis.interval = yMax / 5;
internationalCooperationOption.value.series[0].data = data;
};
const internationalPaperOption = ref({
tooltip: {
trigger: "axis"
},
grid: {
top: "15%",
left: "3%",
right: "4%",
bottom: "3%",
containLabel: true
},
xAxis: {
type: "category",
boundaryGap: false,
data: [],
axisLine: {
lineStyle: {
color: "#E6EBF5"
}
},
grid: {
top: "15%",
left: "3%",
right: "4%",
bottom: "3%",
containLabel: true
axisLabel: {
color: "#606266",
fontSize: 12,
lineHeight: 18,
fontFamily: "Microsoft YaHei"
},
xAxis: {
type: "category",
boundaryGap: false,
data: years,
axisLine: {
lineStyle: {
color: "#E6EBF5"
}
},
axisLabel: {
color: "#606266",
fontSize: 12,
lineHeight: 18,
fontFamily: "Microsoft YaHei"
},
axisTick: {
show: false
}
axisTick: {
show: false
}
},
yAxis: {
type: "value",
min: 0,
max: 5,
interval: 10 / 5,
axisLabel: {
color: "#606266",
fontSize: 12,
fontFamily: "Microsoft YaHei"
},
yAxis: {
type: "value",
min: 0,
max: yMax,
interval: yMax / 5,
axisLabel: {
color: "#606266",
fontSize: 12,
fontFamily: "Microsoft YaHei"
},
splitLine: {
lineStyle: {
type: "dashed",
color: "rgba(231, 243, 255, 1)"
}
splitLine: {
lineStyle: {
type: "dashed",
color: "rgba(231, 243, 255, 1)"
}
},
series: [
{
data: data,
type: "line",
symbol: "none",
smooth: false,
lineStyle: {
color: "#055FC2",
width: 2
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: "rgba(5, 95, 194, 0.2)"
},
{
offset: 1,
color: "rgba(5, 95, 194, 0)"
}
])
}
}
},
series: [
{
data: [],
type: "line",
symbol: "none",
smooth: false,
lineStyle: {
color: "#055FC2",
width: 2
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: "rgba(5, 95, 194, 0.2)"
},
{
offset: 1,
color: "rgba(5, 95, 194, 0)"
}
])
}
]
};
myChart.setOption(option);
};
}
]
});
const initShareChart = () => {
if (!shareChartRef.value) return;
const myChart = echarts.getInstanceByDom(shareChartRef.value) || echarts.init(shareChartRef.value);
// if (!shareChartRef.value) return;
// const myChart = echarts.getInstanceByDom(shareChartRef.value) || echarts.init(shareChartRef.value);
// 处理接口返回的数据,按年份升序排序
const chartData = [...internationalPaper.value].sort((a, b) => a.year - b.year);
......@@ -611,79 +1014,83 @@ const initShareChart = () => {
const maxVal = Math.max(...data, 10);
const yMax = Math.ceil(maxVal / 5) * 5;
const option = {
tooltip: {
trigger: "axis"
},
grid: {
top: "15%",
left: "3%",
right: "4%",
bottom: "3%",
containLabel: true
},
xAxis: {
type: "category",
boundaryGap: false,
data: years,
axisLine: {
lineStyle: {
color: "#E6EBF5"
}
},
axisLabel: {
color: "#606266",
fontSize: 12,
lineHeight: 18,
fontFamily: "Microsoft YaHei"
},
axisTick: {
show: false
}
},
yAxis: {
type: "value",
min: 0,
max: yMax,
interval: yMax / 5,
axisLabel: {
color: "#606266",
fontSize: 12,
fontFamily: "Microsoft YaHei"
},
splitLine: {
lineStyle: {
type: "dashed",
color: "rgba(231, 243, 255, 1)"
}
}
},
series: [
{
data: data,
type: "line",
symbol: "none",
smooth: false,
lineStyle: {
color: "#055FC2",
width: 2
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: "rgba(5, 95, 194, 0.2)"
},
{
offset: 1,
color: "rgba(5, 95, 194, 0)"
}
])
}
}
]
};
myChart.setOption(option);
// const option = {
// tooltip: {
// trigger: "axis"
// },
// grid: {
// top: "15%",
// left: "3%",
// right: "4%",
// bottom: "3%",
// containLabel: true
// },
// xAxis: {
// type: "category",
// boundaryGap: false,
// data: years,
// axisLine: {
// lineStyle: {
// color: "#E6EBF5"
// }
// },
// axisLabel: {
// color: "#606266",
// fontSize: 12,
// lineHeight: 18,
// fontFamily: "Microsoft YaHei"
// },
// axisTick: {
// show: false
// }
// },
// yAxis: {
// type: "value",
// min: 0,
// max: yMax,
// interval: yMax / 5,
// axisLabel: {
// color: "#606266",
// fontSize: 12,
// fontFamily: "Microsoft YaHei"
// },
// splitLine: {
// lineStyle: {
// type: "dashed",
// color: "rgba(231, 243, 255, 1)"
// }
// }
// },
// series: [
// {
// data: data,
// type: "line",
// symbol: "none",
// smooth: false,
// lineStyle: {
// color: "#055FC2",
// width: 2
// },
// areaStyle: {
// color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
// {
// offset: 0,
// color: "rgba(5, 95, 194, 0.2)"
// },
// {
// offset: 1,
// color: "rgba(5, 95, 194, 0)"
// }
// ])
// }
// }
// ]
// };
// myChart.setOption(option);
internationalPaperOption.value.xAxis.data = years;
internationalPaperOption.value.yAxis.max = yMax;
internationalPaperOption.value.yAxis.interval = yMax / 5;
internationalPaperOption.value.series[0].data = data;
};
const handleResize = () => {
......@@ -1131,4 +1538,50 @@ onBeforeUnmount(() => {
font-size: 14px;
}
}
.data-origin-box {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 35px 0;
.data-origin-icon {
width: 16px;
height: 16px;
font-size: 0px;
margin-right: 8px;
img {
width: 100%;
height: 100%;
}
}
.data-origin-text {
font-family: Source Han Sans CN;
font-size: 14px;
color: var(--text-primary-50-color);
}
}
.ai-pane {
position: absolute;
right: 0px;
bottom: 15px;
z-index: 2;
:deep(.ai-pane-wrapper) {
display: none;
}
:deep(.ai-button-wrapper) {
display: flex;
}
&:hover {
width: 100%;
bottom: 0px;
:deep(.ai-pane-wrapper) {
display: block;
}
:deep(.ai-button-wrapper) {
display: none;
}
}
}
</style>
......@@ -12,7 +12,7 @@
{{ headerTitle.department }}
</div>
</div>
<div class="btn"><img :src="icon01" alt="" />切换</div>
<div class="btn" @click="openSanctionModal"><img :src="icon01" alt="" />切换</div>
</div>
<div class="header-nav">
<div
......@@ -44,12 +44,46 @@
<deep-mining v-if="activeIndex === 2"></deep-mining>
<impact-analysis v-if="activeIndex === 3"></impact-analysis>
</div>
<el-dialog
v-model="sanctionModalVisible"
title="制裁事件列表"
width="600px"
:close-on-click-modal="false"
@close="resetModal"
>
<div class="sanction-list">
<div
v-for="item in sanctionList"
:key="item.id"
class="sanction-item"
:class="{ active: selectedSanctionId === item.id }"
@click="selectSanction(item)"
>
{{ item.name }}
</div>
</div>
<template #footer>
<div class="pagination-footer">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="totalElements"
:page-sizes="[10]"
layout="total, prev, pager, next"
background
@current-change="handlePageChange"
/>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
import { ElDialog, ElPagination } from "element-plus";
import sanctionsOverview from "./components/sanctionsOverview/index.vue";
import dataStatistics from "./components/dataStatistics/index.vue";
import deepMining from "./components/deepMining/index.vue";
......@@ -67,6 +101,7 @@ import icon3 from "../assets/icons/icon3.png";
import icon3Active from "../assets/icons/icon3_active.png";
import { getSingleSanctionOverview } from "@/api/exportControlV2.0.js";
import { getSanctionProcess } from "@/api/exportControl";
import router from "@/router";
// 处理点击实体清单原文按钮
......@@ -157,6 +192,77 @@ const handleAnalysisClick = () => {
});
};
// ========== 新增响应式状态 ==========
const sanctionModalVisible = ref(false);
const sanctionList = ref([]);
const selectedSanctionId = ref(null);
const currentPage = ref(1);
const pageSize = ref(10);
const totalElements = ref(0);
// ========== 打开弹窗并加载第一页 ==========
const openSanctionModal = async () => {
sanctionModalVisible.value = true;
console.log("制裁事件列表11:", sanctionList.value);
await fetchSanctionData();
};
// ========== 获取制裁数据 ==========
const fetchSanctionData = async () => {
try {
const res = await getSanctionProcess([1], currentPage.value, 10);
if (res && !!res.content) {
sanctionList.value = res.content || [];
totalElements.value = res.totalElements || 0;
// 如果当前选中的不在新数据中,清空选中
if (selectedSanctionId.value && !sanctionList.value.some(item => item.id === selectedSanctionId.value)) {
selectedSanctionId.value = null;
}
console.log("制裁事件列表:", sanctionList.value);
} else {
sanctionList.value = [];
totalElements.value = 0;
}
} catch (error) {
console.error("获取制裁事件失败:", error);
sanctionList.value = [];
totalElements.value = 0;
}
};
// ========== 翻页处理 ==========
const handlePageChange = async newPage => {
currentPage.value = newPage;
await fetchSanctionData();
};
// ========== 选择某项 ==========
const selectSanction = item => {
selectedSanctionId.value = item.id;
// 可在此处触发其他逻辑,如查看详情
// 替换当前路由参数,并重新加载数据
router.replace({
path: window.location.pathname,
query: {
id: item.id,
sanTypeId: item.sanTypeId
}
});
// 3. 立即重新加载数据(关键!)
getSingleSanctionOverviewData();
// 4. 可选:关闭弹窗
sanctionModalVisible.value = false;
};
// ========== 关闭弹窗时重置 ==========
const resetModal = () => {
sanctionList.value = [];
selectedSanctionId.value = null;
currentPage.value = 1;
totalElements.value = 0;
};
onMounted(() => {
getUrlParams();
getSingleSanctionOverviewData();
......@@ -350,4 +456,40 @@ onMounted(() => {
background-color: #f7f8f9;
}
}
.sanction-list {
max-height: 400px;
overflow-y: auto;
padding: 0 10px;
}
.sanction-item {
padding: 7px;
margin: 6px 0;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
color: #333;
transition: background-color 0.2s;
}
.sanction-item:hover {
background-color: #f5f7fa;
}
.sanction-item.active {
background-color: #e6f0ff;
color: #1890ff;
font-weight: bold;
}
.pagination-footer {
display: flex;
justify-content: center;
margin-top: 12px;
}
:deep(.el-pagination.is-background .el-pager li:not(.is-disabled).is-active) {
background-color: #0e78f1;
}
</style>
{
"content": [
{
"id": 138,
"sanTypeId": 1,
"postDate": "2025-10-08",
"name": "实体清单的增补与修订",
"summary": "在该规则中,工业和安全局(BIS)修订了《出口管理条例》(EAR),在中华人民共和国(中国)(19)、土耳其(9)和阿拉伯联合酋长国(阿联酋)(1)的目的地下的实体清单中增加了29个条目(26个实体和3个地址)。美国政府认定这些实体的行为违背了美国的国家安全或外交政策利益。",
"cnEntityCount": 15,
"techDomainList": ["新能源", "先进制造", "航空航天", "人工智能", "新一代通信网络"],
"orgId": "241",
"orgName": "商务部工业与安全局",
"orgLogoUrl": "http://8.140.26.4:10010/kjb-files/images/org/round/BIS美国商务部工业与安全局.png"
},
{
"id": 121,
"sanTypeId": 1,
"postDate": "2025-09-16",
"name": "美国工业与安全局新增32个实体至实体清单,多数为中国企业",
"summary": "美国工业与安全局(BIS)通过在《出口管理条例》(EAR)中新增32个实体至实体清单。这些实体分别位于中国(23个)、印度(1个)、伊朗(1个)、新加坡(1个)、台湾(1个)、土耳其(3个)和阿拉伯联合酋长国(UAE)(2个)。这些实体已被美国政府认定为违反美国国家安全或外交政策利益。其中,中国的23个实体包括中国科学院国家授时中心、北京复旦微电子科技有限公司、上海复旦微电子有限公司等。这些实体因收购或试图收购美国原产物品以支持中国军事及国防相关的太空领域活动、量子技术能力发展以及参与中国先进计算和集成电路制造与分销领域而被加入。此外,还包括一些向中国军队、政府和安全部门供应产品的公司。被列入实体清单的实体将面临更严格的许可证要求,并实行拒绝推定的许可证审查政策。此次更新还修正了部分现有条目中的拼写错误,涉及多个国家和地区,其中包括中国的11个实体。",
"cnEntityCount": 23,
"techDomainList": ["航空航天", "人工智能", "集成电路", "先进制造", "生物科技", "太空", "量子科技"],
"orgId": "241",
"orgName": "商务部工业与安全局",
"orgLogoUrl": "http://8.140.26.4:10010/kjb-files/images/org/round/BIS美国商务部工业与安全局.png"
},
{
"id": 85,
"sanTypeId": 1,
"postDate": "2025-03-28",
"name": "美国将12个中国和台湾实体列入出口管制清单",
"summary": "根据美国商务部工业与安全局(BIS)发布的最终规则,12个实体被列入实体清单,其中11个位于中国,1个位于台湾地区。这些实体包括北京人工智能研究院、北京创新智慧科技有限公司、河南鼎鑫信息产业有限公司、浪潮(北京)电子信息产业有限公司及其相关子公司等。这些实体被认定为参与或支持中国的军事现代化项目,特别是开发用于国防目的的大规模人工智能模型和先进计算芯片,以及参与超级计算机的开发。对于这些实体,BIS对所有受EAR管辖的物品实施许可证要求,并采取推定拒绝的许可审查政策。此外,这些实体还被赋予脚注4标识,意味着“受EAR管辖的物品”包括根据EAR第734.9(e)(2)条受EAR管辖的外国生产物品。此决定自2025年3月25日起生效。",
"cnEntityCount": 12,
"techDomainList": ["先进制造", "集成电路", "人工智能"],
"orgId": "241",
"orgName": "商务部工业与安全局",
"orgLogoUrl": "http://8.140.26.4:10010/kjb-files/images/org/round/BIS美国商务部工业与安全局.png"
},
{
"id": 110,
"sanTypeId": 1,
"postDate": "2025-03-28",
"name": "美国商务部工业和安全局更新实体清单,新增70个涉及多国的实体",
"summary": "美国商务部工业和安全局(BIS)根据《出口管理条例》(EAR)对实体清单进行了修订,将70个实体列入实体清单,其中包括多个中国实体。这些实体被认定为违反了美国国家安全或外交政策利益。涉及中国的实体包括航空计算技术研究院、航天星科技应用有限公司、空军工程大学、安徽科华科技贸易有限公司、重庆关联光电有限公司等。这些实体因参与支持中国军事现代化、量子技术发展以及向其他实体清单上的中国企业供货等活动而被列入。对于这些实体,所有受EAR管辖的物品均需申请许可证,并且许可证申请将按否决推定审查。此外,规则还对实体清单中的四个现有条目进行了修改,涉及法国、伊朗、塞内加尔和英国的实体。该规则自2025年3月28日起生效。",
"cnEntityCount": 40,
"techDomainList": ["新一代通信网络", "先进制造", "航空航天", "新材料", "量子科技", "集成电路", "太空", "核"],
"orgId": "241",
"orgName": "商务部工业与安全局",
"orgLogoUrl": "http://8.140.26.4:10010/kjb-files/images/org/round/BIS美国商务部工业与安全局.png"
},
{
"id": 64,
"sanTypeId": 1,
"postDate": "2025-01-16",
"name": "美国将16个实体列入出口管制清单,其中14家为中国企业",
"summary": "美国工业与安全局(BIS)通过修订《出口管理条例》(EAR),将16个实体列入实体清单,其中包括14家位于中国的公司和2家位于新加坡的公司。这些实体被认定为参与了支持或直接促进先进计算集成电路(ICs)的发展,从而推动中国发展先进武器系统、大规模杀伤性武器以及高科技监控应用,并且存在向被列入实体的华为公司转移的风险。涉及的中国企业包括成都算峰科技有限公司、福建索菲昂科技有限公司、福建算信科技有限公司等。对于这些新增实体,BIS实施对所有受EAR管辖物品的许可证要求,并采取“推定拒绝”的许可证审查政策。此外,该规则自2025年1月16日起生效。",
"cnEntityCount": 14,
"techDomainList": ["人工智能", "集成电路"],
"orgId": "241",
"orgName": "商务部工业与安全局",
"orgLogoUrl": "http://8.140.26.4:10010/kjb-files/images/org/round/BIS美国商务部工业与安全局.png"
},
{
"id": 118,
"sanTypeId": 1,
"postDate": "2025-01-16",
"name": "美国将11家中国实体列入《实体清单》并修订印度相关条目",
"summary": "美国工业与安全局(BIS)通过最终规则,将11个中国实体列入《实体清单》,这些实体包括北京智谱华章科技有限公司、北京灵芯智能科技有限公司等。这些实体因参与先进人工智能研究的开发与集成,推动了中国的军事现代化,违反了美国国家安全和外交政策利益。所有受EAR管辖的商品均需许可,许可审查政策为“推定拒绝”。此外,BIS还修改了《实体清单》中关于印度的一个现有条目,移除了三个实体(印度稀土公司、英迪拉·甘地原子能研究中心和巴哈夫原子能研究中心),以支持美印两国在能源合作及关键矿产和清洁能源供应链方面的合作。",
"cnEntityCount": 11,
"techDomainList": ["人工智能", "先进制造", "集成电路"],
"orgId": "241",
"orgName": "商务部工业与安全局",
"orgLogoUrl": "http://8.140.26.4:10010/kjb-files/images/org/round/BIS美国商务部工业与安全局.png"
},
{
"id": 107,
"sanTypeId": 1,
"postDate": "2025-01-06",
"name": "实体清单修订",
"summary": "在本规则中,工业与安全局(BIS)通过将13个实体下的13个条目添加到实体清单中,对《出口管理条例》(EAR)进行了修订。这些条目在实体清单中分别位于缅甸(1个)、中华人民共和国(中国)(11个)和巴基斯坦(1个)目的地项下。美国政府已认定这些实体的行为违背美国国家安全和/或外交政策利益。本规则还通过进行某些编辑性更正和澄清来修订EAR。BIS进行这些更正和澄清是为了尽量减少混淆,不妨碍商业的自由流动。",
"cnEntityCount": 11,
"techDomainList": ["新材料", "集成电路", "先进制造", "航空航天", "新一代通信网络", "量子科技", "人工智能"],
"orgId": "241",
"orgName": "商务部工业与安全局",
"orgLogoUrl": "http://8.140.26.4:10010/kjb-files/images/org/round/BIS美国商务部工业与安全局.png"
},
{
"id": 14,
"sanTypeId": 1,
"postDate": "2024-12-11",
"name": "实体清单增补项",
"summary": "根据本规定,工业与安全局(BIS)通过将8个实体添加到实体清单中,对《出口管理条例》(EAR)进行了修订,这些实体分别位于缅甸(2个)、中华人民共和国(中国)(2个)和俄罗斯(4个)。美国政府已判定这些实体的行为违背了美国的国家安全或外交政策利益。",
"cnEntityCount": 2,
"techDomainList": ["人工智能", "先进制造", "航空航天", "新一代通信网络"],
"orgId": "241",
"orgName": "商务部工业与安全局",
"orgLogoUrl": "http://8.140.26.4:10010/kjb-files/images/org/round/BIS美国商务部工业与安全局.png"
},
{
"id": 4,
"sanTypeId": 1,
"postDate": "2024-12-05",
"name": "实体清单的增补与修订;经验证最终用户(VEU)计划的移除",
"summary": "在最终规则中,工业与安全局(BIS)通过将140个实体加入实体清单,对《出口管理条例》(EAR)进行了修订。这些实体被列入中国(中华人民共和国)、日本、韩国和新加坡目的地的实体清单中,并被美国政府认定为行为违背美国国家安全和外交政策利益。本最终规则还修改了实体清单上的14个现有条目,包括对中国项下14个条目的修订。此最终规则与BIS的临时最终规则《外国生产直接产品规则增补及对先进计算和半导体制造物项管控的完善》(0694-AJ74)同时发布,后者对先进计算物项和半导体制造物项的EAR管控进行了额外修改。本最终规则是确保对这些物项实施适当EAR管控的更广泛努力的一部分,包括涉及被加入实体清单的实体的交易,以及正在修改的实体清单现有条目。所有这些实体(新增及修改的)均涉及“先进节点集成电路”(“先进节点IC”)和/或半导体制造物项的开发与生产,和/或支持了中国政府的军民融合发展战略。此外,本最终规则将新增的9个实体及修改的7个条目指定为适用涉及外国生产物项的实体特定限制的实体。本最终规则还通过从已验证最终用户(VEU)计划中移除三个实体,对EAR进行了修订。",
"cnEntityCount": 125,
"techDomainList": ["先进制造", "集成电路", "新材料"],
"orgId": "241",
"orgName": "商务部工业与安全局",
"orgLogoUrl": "http://8.140.26.4:10010/kjb-files/images/org/round/BIS美国商务部工业与安全局.png"
},
{
"id": 49,
"sanTypeId": 1,
"postDate": "2024-11-01",
"name": "实体清单中实体的增补与修订",
"summary": "在本规则中,工业与安全局(Bureau of Industry and Security,简称BIS)通过向实体清单添加42个条目下的40个实体和4个条目下的4个地址,修订了《出口管理条例》(Export Administration Regulations,简称EAR)。这些条目被列入实体清单,目的地包括中华人民共和国(中国)(11个)、印度(5个)、马来西亚(2个)、俄罗斯(13个)、新加坡(1个)和土耳其(14个)。两个实体被添加到两个目的地下的实体清单中,这解释了总数上的差异。本最终规则还修改了实体清单上52个现有条目,这些条目位于中国、爱沙尼亚、芬兰、印度、土耳其、阿拉伯联合酋长国(UAE)和英国。美国政府已确定这些实体的行为违背了美国的国家安全或外交政策利益。本最终规则与BIS的另一项最终规则《根据〈出口管理条例〉实施针对俄罗斯和白俄罗斯的额外出口管制;及澄清》(RIN 0694-AJ93)同时发布,该规则包含了与俄罗斯和白俄罗斯相关的出口管制的额外变更。",
"cnEntityCount": 5,
"techDomainList": ["先进制造", "航空航天", "海洋", "生物科技", "集成电路", "新材料"],
"orgId": "241",
"orgName": "商务部工业与安全局",
"orgLogoUrl": "http://8.140.26.4:10010/kjb-files/images/org/round/BIS美国商务部工业与安全局.png"
}
],
"pageable": {
"sort": {
"sorted": true,
"unsorted": false,
"empty": false
},
"pageNumber": 0,
"pageSize": 10,
"offset": 0,
"unpaged": false,
"paged": true
},
"last": false,
"totalPages": 15,
"totalElements": 141,
"sort": {
"sorted": true,
"unsorted": false,
"empty": false
},
"numberOfElements": 10,
"first": true,
"size": 10,
"number": 0,
"empty": false
}
......@@ -2033,13 +2033,13 @@ onMounted(async () => {
.up {
width: 32px;
height: 32px;
background-image: url("src/views/innovationSubject/assets/images/icon-up.png");
background-image: url("./assets/images/icon-up.png");
}
.down {
width: 32px;
height: 32px;
background-image: url("src/views/innovationSubject/assets/images/icon-down.png");
background-image: url("./assets/images/icon-down.png");
}
}
......
......@@ -160,7 +160,7 @@
</AnalysisBox>
</div>
<div class="box2">
<AnalysisBox title="共识观点列表" :showAllBtn="true" v-if="isBox2 || isAnalysisLoading">
<AnalysisBox title="核心观点分析" :showAllBtn="true" v-if="isBox2 || isAnalysisLoading">
<div class="box2-main">
<div class="empty-image" v-if="isBox2 && !isAnalysisLoading">
<img src="../assets/images/empty-image.png" alt="" />
......@@ -228,7 +228,11 @@
<div v-for="(sv, svIdx) in item.sourceViewDetails" :key="`${sv.report_id}-${sv.view_id}-${svIdx}`"
class="source-view-detail">
<div class="source-view-detail-title">
<span class="source-view-detail-title-text">{{ getSourceViewDisplayTitle(sv, svIdx) }}</span>
<span
class="source-view-detail-title-text"
:class="{ 'is-clickable-report': hasReportLinkForSourceView(sv) }"
@click.stop="handleOpenReportOriginalFromSource(sv)"
>{{ getSourceViewDisplayTitle(sv) }}</span>
<span class="source-view-detail-org" v-if="sv.thinktankName || sv.thinktankLogoUrl">
<img v-if="sv.thinktankLogoUrl" :src="sv.thinktankLogoUrl" alt="" />
<span class="source-view-detail-org-text">{{ sv.thinktankName }}</span>
......@@ -274,8 +278,11 @@
<div v-for="(sv, svIdx) in item.sourceViewDetails"
:key="`${sv.report_id}-${sv.view_id}-${svIdx}`" class="source-view-detail">
<div class="source-view-detail-title">
<span class="source-view-detail-title-text">{{ getSourceViewDisplayTitle(sv, svIdx)
}}</span>
<span
class="source-view-detail-title-text"
:class="{ 'is-clickable-report': hasReportLinkForSourceView(sv) }"
@click.stop="handleOpenReportOriginalFromSource(sv)"
>{{ getSourceViewDisplayTitle(sv) }}</span>
<span class="source-view-detail-org" v-if="sv.thinktankName || sv.thinktankLogoUrl">
<img v-if="sv.thinktankLogoUrl" :src="sv.thinktankLogoUrl" alt="" />
<span class="source-view-detail-org-text">{{ sv.thinktankName }}</span>
......@@ -323,7 +330,7 @@ const sort = ref("");
const searchPolicy = ref("");
const isBox2 = ref(true)
const isAnalysisLoading = ref(false)
const isBeingAnalysisExpanded = ref(false)
const isBeingAnalysisExpanded = ref(true)
const beingAnalysisContent = ref("")
const beingAnalysisContentRef = ref(null)
const activeOpinionTab = ref('consensus')
......@@ -435,11 +442,23 @@ const getViewpointDetailForSource = (reportId, viewId) => {
thinktankLogoUrl: ""
}
}
/** 展开区标题:优先中文标题,否则英文;无标题则返回空串(由上游过滤) */
const getSourceViewDisplayTitle = (sv, idx) => {
const zh = String(sv.titleZh ?? "").trim()
/** 根据 report_id 找到报告名(用于展开区标题展示) */
const getReportNameById = (reportId) => {
const id = String(reportId ?? "")
if (!id) return ""
const list = selectedReportList.value || []
const hit = Array.isArray(list) ? list.find((r) => String(r?.id ?? "") === id) : null
return String(hit?.name ?? "").trim()
}
/** 展开区标题:显示报告名《xxx》(优先);否则回退中文标题/英文标题;无标题则返回空串(由上游过滤) */
const getSourceViewDisplayTitle = (sv) => {
const reportName = String(sv?.reportName ?? "").trim()
if (reportName) return `《${reportName}》`
const fromId = getReportNameById(sv?.report_id)
if (fromId) return `《${fromId}》`
const zh = String(sv?.titleZh ?? "").trim()
if (zh) return zh
const en = String(sv.title ?? "").trim()
const en = String(sv?.title ?? "").trim()
if (en) return en
return ""
}
......@@ -449,6 +468,20 @@ const getSourceViewDisplayContent = (sv) => {
if (zh) return zh
return String(sv.content ?? "").trim()
}
/** 是否存在可跳转的报告 id(source_views 的 report_id) */
const hasReportLinkForSourceView = (sv) => Boolean(String(sv?.report_id ?? "").trim())
/** 点击报告标题:新标签打开该报告原文页 */
const handleOpenReportOriginalFromSource = (sv) => {
const id = String(sv?.report_id ?? "").trim()
if (!id) return
const route = router.resolve({
name: "ReportOriginal",
params: { id }
})
window.open(route.href, "_blank")
}
const tryParseAnswerFromStreamText = (text) => {
const lines = String(text || "")
.split(/\r?\n/)
......@@ -492,9 +525,11 @@ const consensusList = computed(() => {
const sourceViewDetails = sourceViews
.map((v) => {
const detail = getViewpointDetailForSource(v.report_id, v.view_id)
const reportName = getReportNameById(v.report_id)
return {
report_id: v.report_id,
view_id: v.view_id,
reportName,
titleZh: detail.titleZh,
contentZh: detail.contentZh,
title: detail.title,
......@@ -504,14 +539,15 @@ const consensusList = computed(() => {
}
})
.filter((sv) => {
const title = getSourceViewDisplayTitle(sv, 0)
const title = getSourceViewDisplayTitle(sv)
return Boolean(title)
})
const uniqueReportCount = new Set(sourceViewDetails.map((sv) => String(sv.report_id ?? ""))).size
const sourceViewText = sourceViews.map((v) => `report_id: ${v.report_id}, view_id: ${v.view_id}`).join(";")
return {
id: `consensus-${index + 1}`,
consensusContent: item?.consensus_content || "",
reportCount: sourceViewDetails.length,
reportCount: uniqueReportCount,
sourceViewText,
sourceViewDetails
}
......@@ -525,9 +561,11 @@ const differenceList = computed(() => {
const sourceViewDetails = sourceViews
.map((v) => {
const detail = getViewpointDetailForSource(v.report_id, v.view_id)
const reportName = getReportNameById(v.report_id)
return {
report_id: v.report_id,
view_id: v.view_id,
reportName,
titleZh: detail.titleZh,
contentZh: detail.contentZh,
title: detail.title,
......@@ -537,19 +575,43 @@ const differenceList = computed(() => {
}
})
.filter((sv) => {
const title = getSourceViewDisplayTitle(sv, 0)
const title = getSourceViewDisplayTitle(sv)
return Boolean(title)
})
const uniqueReportCount = new Set(sourceViewDetails.map((sv) => String(sv.report_id ?? ""))).size
const sourceViewText = sourceViews.map((v) => `report_id: ${v.report_id}, view_id: ${v.view_id}`).join(";")
return {
id: `difference-${index + 1}`,
disagreementContent: item?.disagreement_content || "",
reportCount: sourceViewDetails.length,
reportCount: uniqueReportCount,
sourceViewText,
sourceViewDetails
}
})
})
// 默认展开:每次分析结果就绪后,共识/分歧列表第一条展开,其余关闭
watch(
consensusList,
(list) => {
if (!Array.isArray(list) || list.length === 0) return
if (openConsensusIds.value.size > 0) return
if (!list[0]?.id) return
openConsensusIds.value = new Set([list[0].id])
},
{ immediate: true }
)
watch(
differenceList,
(list) => {
if (!Array.isArray(list) || list.length === 0) return
if (openDifferencesIds.value.size > 0) return
if (!list[0]?.id) return
openDifferencesIds.value = new Set([list[0].id])
},
{ immediate: true }
)
// 近N年发布(用于 startDate)
const selectedYears = ref(5);
const yearsOptions = [
......@@ -585,9 +647,12 @@ const handleAnalysis = async () => {
if (!canProceed.value) return
isBox2.value = false
isAnalysisLoading.value = true
isBeingAnalysisExpanded.value = false
isBeingAnalysisExpanded.value = true
beingAnalysisContent.value = ""
domainViewAnalysisRes.value = null
// 默认:共识/分歧第一条展开,其余关闭(每次开始分析先清空旧展开状态)
openConsensusIds.value = new Set()
openDifferencesIds.value = new Set()
// 每次进入“开始分析”默认回到共识观点,避免上次状态残留导致内容错位
activeOpinionTab.value = 'consensus'
await handlePostReportDomainViewAnalysis()
......@@ -601,6 +666,8 @@ const handleBack = () => {
beingAnalysisContent.value = ""
// 返回选择时也重置,确保下次进入分析展示一致
activeOpinionTab.value = 'consensus'
openConsensusIds.value = new Set()
openDifferencesIds.value = new Set()
}
const pageSize = 10;
const total = ref(0);
......@@ -1029,7 +1096,7 @@ onMounted(async () => {
.being-analysis-detail-box {
width: 1063px;
height: 160px;
height: 260px;
background-color: rgb(246, 250, 255);
border-radius: 10px;
......@@ -1087,7 +1154,7 @@ onMounted(async () => {
.being-analysis-box-content {
width: 983px;
height: 104px;
height: 204px;
margin-left: 40px;
overflow-y: auto;
overflow-x: hidden;
......
......@@ -14,13 +14,17 @@
<div class="tag">{{ value.industryName }}</div>
</div> -->
<AreaTag v-for="value, index in thinkInfo.tags" :key="index" :tagName="value.industryName"></AreaTag>
<AreaTag
v-for="(value, index) in thinkInfo.tags"
:key="index"
:tagName="value.industryName"
></AreaTag>
</div>
</div>
</div>
<div class="header-top-right">
<div class="image-name-box">
<div class="image"> <img :src=thinkInfo.thinkTankLogoUrl alt="" /></div>
<div class="image"><img :src="thinkInfo.thinkTankLogoUrl" alt="" /></div>
<div class="name">{{ thinkInfo.thinkTankName }}</div>
</div>
<div class="time">{{ thinkInfo.times }}</div>
......@@ -50,24 +54,24 @@
</div>
<div class="text">{{ "查看官网" }}</div>
</div> -->
<div class="btn">
<!-- <div class="btn">
<div class="icon">
<img src="./images/btn-icon2.png" alt="" />
</div>
<div class="text" @click="goToOfficialWebsite()">{{ "查看官网" }}</div>
</div>
</div> -->
<div class="btn">
<div class="icon">
<img src="./images/btn-icon2.png" alt="" />
</div>
<div class="text" @click="toReport()">{{ "报告原文" }}</div>
</div>
<div class="btn" @click="handleDownloadDocument">
<!-- <div class="btn" @click="handleDownloadDocument">
<div class="icon">
<img src="./images/btn-icon3.png" alt="" />
</div>
<div class="text">{{ "文档下载" }}</div>
</div>
</div> -->
<div class="btn btn1" @click="handleAnalysisClick">
<div class="icon">
<img src="./images/btn-icon4.png" alt="" />
......@@ -95,7 +99,7 @@ const router = useRouter();
const route = useRoute();
const reportUrl = ref("");
const thinkInfo = ref({});
const reportList = ref({})
const reportList = ref({});
// 获取报告全局信息
const handleGetThinkTankReportSummary = async () => {
......@@ -105,7 +109,7 @@ const handleGetThinkTankReportSummary = async () => {
if (res.code === 200 && res.data) {
reportUrl.value = res.data.reportUrl;
thinkInfo.value = res.data;
console.log(reportUrl.value, 'reportUrl.value')
console.log(reportUrl.value, "reportUrl.value");
}
} catch (error) {
console.error("获取报告全局信息error", error);
......@@ -117,9 +121,7 @@ const handleGetThinkTankReport = async () => {
const res = await getThinkTankReportRelated(router.currentRoute._value.params.id);
console.log("报告全局信息", res);
if (res.code === 200 && res.data) {
reportList.value = res.data;
}
} catch (error) {
console.error("获取相关报告error", error);
......@@ -133,6 +135,8 @@ const toReport = () => {
id: router.currentRoute._value.params.id
}
});
console.log("route.href", route.href);
window.open(route.href, "_blank");
};
const tabActiveName = ref("报告分析");
......@@ -167,7 +171,7 @@ const goToOfficialWebsite = () => {
const downloadOnePdf = async (url, filename) => {
const response = await fetch(url, {
method: "GET",
headers: { "Content-Type": "application/pdf" },
headers: { "Content-Type": "application/pdf" }
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const blob = await response.blob();
......@@ -209,10 +213,9 @@ const handleDownloadDocument = async () => {
try {
const { ElMessage } = await import("element-plus");
ElMessage.error("PDF 下载失败,请稍后重试");
} catch (_) { }
} catch (_) {}
}
};
</script>
<style lang="scss" scoped>
......@@ -305,7 +308,6 @@ const handleDownloadDocument = async () => {
line-height: 24px;
letter-spacing: 0px;
text-align: right;
}
.image {
......@@ -314,12 +316,9 @@ const handleDownloadDocument = async () => {
margin-top: 5px;
img {
width: 100%;
height: 100%;
}
}
}
......@@ -447,4 +446,4 @@ const handleDownloadDocument = async () => {
width: 100%;
}
}
</style>
\ No newline at end of file
</style>
......@@ -103,17 +103,19 @@
</div>
<div class="right-footer-box">
<div class="relatedBills" v-for="(item, index) in box1DataItem.relatedBills" :key="index" v-show="item">
<div class="tag">{{ "政令" }}</div>
<div class="tag">{{ "法案" }}</div>
<div class="tag">{{ "科技领域相似" }}</div>
<div class="relatedBills-content">{{ item.name }}</div>
<div class="footer-image"> <img src="../images/image-right.png" alt="" /></div>
<div class="footer-image" @click.stop="handleBillMoreClick(item)"> <img src="../images/image-right.png"
alt="" /></div>
</div>
<div class="relatedAdministrativeOrders" v-for="(item, index) in box1DataItem.relatedAdministrativeOrders"
v-show="item" :key="index">
<div class="tag">{{ "政令" }}</div>
<div class="tag">{{ "科技领域相似" }}</div>
<div class="relatedAdministrativeOrders-content">{{ item.content }}</div>
<div class="footer-image"> <img src="../images/image-right.png" alt="" /></div>
<div class="footer-image" @click.stop="handleAdministrativeMoreClick(item)"> <img
src="../images/image-right.png" alt="" /></div>
</div>
</div>
</div>
......@@ -217,6 +219,29 @@ const handleTagClick = tag => {
activeItemIndex.value = 0;
updatePageData();
};
const handleBillMoreClick = (bill) => {
const billId = bill?.id;
if (!billId) {
return;
}
const route = router.resolve({
path: "/billLayout/bill/introduction",
query: { billId: String(billId) }
});
window.open(route.href, "_blank");
};
/** 政策建议关联法案:新标签页打开政令介绍页*/
const handleAdministrativeMoreClick = (ad) => {
const id = ad?.bcId;
if (!id) {
return;
}
const route = router.resolve({
path: "/decreeLayout/overview/introduction",
query: { id: String(id) }
});
window.open(route.href, "_blank");
};
const handleSearchOpinions = () => {
// 搜索时默认切回“全部”标签
activeTag.value = "";
......
......@@ -43,7 +43,7 @@
<!-- 多个作者:显示第一个 + 等 -->
<span v-else>
{{ reportAuthors[0].name }}
{{ reportAuthors[0].name }}{{ reportAuthors.length }}
</span>
</template>
......@@ -57,7 +57,7 @@
@error="() => { if (author.avatar) author.avatar = null; }" /></div>
<div class="author-text">
<div class="author-name">{{ author.name }}</div>
<div class="author-position">{{ author.job }}</div>
</div>
</div>
</div>
......@@ -67,21 +67,26 @@
<div class="box5">
<AnalysisBox title="报告关键词云" :showAllBtn="true">
<div class="box5-main">
<div class="box5Chart">
<!-- 有数据后再挂载子组件:子组件仅在 onMounted 初始化,异步数据到达后需 v-if + key 强制重新挂载 -->
<WordCloudChart v-if="box5Data.length" :key="box5WordCloudKey" :data="box5Data" width="100%"
height="100%" />
</div>
<div class="box5-footer">
<TipTab :text="REPORT_ANALYSIS_TIP_BOX5" />
<template v-if="!hasBox5ChartData">
<el-empty class="box5-el-empty" description="暂无数据" :image-size="100" />
</template>
<template v-else>
<div class="box5Chart">
<!-- 有数据后再挂载子组件:子组件仅在 onMounted 初始化,异步数据到达后需 v-if + key 强制重新挂载 -->
<WordCloudChart v-if="box5Data.length" :key="box5WordCloudKey" :data="box5Data" width="100%"
height="100%" />
</div>
<div class="box5-footer">
<TipTab :text="REPORT_ANALYSIS_TIP_BOX5" />
</div>
<div class="ai-wrap" @mouseenter="handleSwitchAiContentShowBox5(true)">
<AiButton />
</div>
<div class="ai-content" v-if="isShowAiContentBox5" @mouseleave="handleSwitchAiContentShowBox5(false)">
<AiPane :aiContent="aiContentBox5" />
</div>
</div>
<div class="ai-content" v-if="isShowAiContentBox5" @mouseleave="handleSwitchAiContentShowBox5(false)">
<AiPane :aiContent="aiContentBox5" />
</div>
</template>
</div>
</AnalysisBox>
</div>
......@@ -254,7 +259,8 @@ const props = defineProps({
});
const REPORT_ANALYSIS_TIP_BOX5 =
"智库报告关键词云,数据来源:美国兰德公司官网";
const isShowAiContentBox5 = ref(false);
// 刷新后默认展示「报告关键词云」AI 总结
const isShowAiContentBox5 = ref(true);
const aiContentBox5 = ref("");
const isBox5InterpretLoading = ref(false);
const handleSwitchAiContentShowBox5 = (val) => {
......@@ -467,6 +473,7 @@ const box2Data = ref([
]);
// 报告关键词云
const box5Data = ref([]);
const hasBox5ChartData = computed(() => Array.isArray(box5Data.value) && box5Data.value.length > 0);
/** 词云子组件不 watch 数据,每次接口成功有数据时递增 key,强制重新挂载以触发 onMounted */
const box5WordCloudKey = ref(0);
......@@ -489,6 +496,10 @@ const handleGetThinkTankReportIndustryCloud = async () => {
if (data.length) {
box5WordCloudKey.value += 1;
}
// 刷新后默认展开 AI:数据就绪即触发解读
if (isShowAiContentBox5.value) {
fetchBox5ChartInterpretation();
}
} else {
box5Data.value = [];
}
......@@ -581,11 +592,16 @@ const handleGetThinkTankReportViewpoint = async () => {
const res = await getThinkTankReportViewpoint(params);
console.log("核心论点", res.data);
if (res.code === 200 && res.data) {
majorOpinions.value = res.data.content || [];
const nextOpinions = res.data.content || [];
majorOpinions.value = nextOpinions;
total.value = res.data.totalElements || 0;
// 重置展开状态
expandedOpinionKeys.value = new Set();
// 默认:第一条展开,其余关闭
const nextExpandedKeys = new Set();
if (Array.isArray(nextOpinions) && nextOpinions.length > 0) {
nextExpandedKeys.add(getOpinionExpandKey(nextOpinions[0], 0));
}
expandedOpinionKeys.value = nextExpandedKeys;
}
} catch (error) {
console.error("获取主要观点error", error);
......@@ -927,9 +943,18 @@ onMounted(() => {
display: flex;
justify-content: space-between;
align-items: center;
position: absolute;
bottom: 20px;
left: 32px;
}
.ai-wrap {
position: absolute;
bottom: 18px;
right: 0;
cursor: pointer;
}
......@@ -942,12 +967,7 @@ onMounted(() => {
}
.ai-wrap {
position: relative;
cursor: pointer;
}
}
}
......
......@@ -252,7 +252,7 @@
<div class="file" v-for="(file, idxx) in item.relatedBills" :key="`${file.id}-${idxx}`">
<div class="type">法案</div>
<div class="title">{{ file.name }}</div>
<div class="more">
<div class="more" @click.stop="handleBillMoreClick(file)">
<img src="./images/arrow-right.png" alt="" />
</div>
</div>
......@@ -260,7 +260,7 @@
:key="file.bcId != null ? String(file.bcId) + '-' + idxx : idxx">
<div class="type">政令</div>
<div class="title">{{ file.content }}</div>
<div class="more">
<div class="more" @click.stop="handleAdministrativeMoreClick(file)">
<img src="./images/arrow-right.png" alt="" />
</div>
</div>
......@@ -302,11 +302,46 @@ import AiButton from "@/components/base/Ai/AiButton/index.vue";
import AiPane from "@/components/base/Ai/AiPane/index.vue";
import TipTab from "@/views/thinkTank/TipTab/index.vue";
import defaultNewsIcon from "@/assets/icons/default-icon-news.png";
import AreaTag from "@/components/base/AreaTag/index.vue";
import { useRouter } from "vue-router";
const router = useRouter();
/** 与 AreaTag 一致的领域色(取 tag 的文字色) */
const AREA_TAG_COLOR_BY_NAME = {
"人工智能": "rgba(245, 34, 45, 1)", // tag1
"生物科技": "rgba(19, 168, 168, 1)", // tag2
"新一代通信网络": "rgba(5, 95, 194, 1)", // tag3
// 兼容常见写法
"通信网络": "rgba(5, 95, 194, 1)",
"量子科技": "rgba(114, 46, 209, 1)", // tag4
"新能源": "rgba(82, 196, 26, 1)", // tag5
"集成电路": "rgba(22, 119, 255, 1)", // tag6
"海洋": "rgba(15, 120, 199, 1)", // tag7
"先进制造": "rgba(250, 173, 20, 1)", // tag8
"新材料": "rgba(250, 140, 22, 1)", // tag9
"航空航天": "rgba(47, 84, 235, 1)", // tag10
"太空": "rgba(47, 84, 235, 1)", // tag11
"深海": "rgba(73, 104, 161, 1)", // tag12
"极地": "rgba(133, 165, 255, 1)", // tag13
"核": "rgba(250, 84, 28, 1)", // tag14
"其他": "rgba(82, 196, 26, 1)" // tag15
};
const AREA_TAG_FALLBACK_COLORS = [
"rgba(5, 95, 194, 1)",
"rgba(245, 34, 45, 1)",
"rgba(19, 168, 168, 1)",
"rgba(250, 140, 22, 1)",
"rgba(114, 46, 209, 1)",
"rgba(82, 196, 26, 1)",
"rgba(22, 119, 255, 1)",
"rgba(250, 84, 28, 1)",
"rgba(47, 84, 235, 1)"
];
const getAreaTagColor = (name, idx = 0) =>
AREA_TAG_COLOR_BY_NAME[name] || AREA_TAG_FALLBACK_COLORS[idx % AREA_TAG_FALLBACK_COLORS.length];
/** 与智库概览 TipTab 文案格式一致(政策追踪-美国国会) */
const POLICY_TRACKING_TIP_BOX1 =
"智库报告中政策建议的领域分布情况,数据来源:美国兰德公司官网";
......@@ -320,7 +355,8 @@ const POLICY_FILTER_ALL_AREA = "全部领域";
const POLICY_FILTER_ALL_TIME = "全部时间";
const POLICY_FILTER_ALL_DEPT = "全部部门";
const isShowAiContentPolicyPt1 = ref(false);
// 刷新后默认展示 3 个图表 AI 总结
const isShowAiContentPolicyPt1 = ref(true);
const aiContentPolicyPt1 = ref("");
const isPolicyPt1InterpretLoading = ref(false);
const handleSwitchAiContentShowPolicyPt1 = (val) => {
......@@ -330,7 +366,7 @@ const handleSwitchAiContentShowPolicyPt1 = (val) => {
}
};
const isShowAiContentPolicyPt2 = ref(false);
const isShowAiContentPolicyPt2 = ref(true);
const aiContentPolicyPt2 = ref("");
const isPolicyPt2InterpretLoading = ref(false);
const handleSwitchAiContentShowPolicyPt2 = (val) => {
......@@ -340,7 +376,7 @@ const handleSwitchAiContentShowPolicyPt2 = (val) => {
}
};
const isShowAiContentPolicyPt3 = ref(false);
const isShowAiContentPolicyPt3 = ref(true);
const aiContentPolicyPt3 = ref("");
const isPolicyPt3InterpretLoading = ref(false);
const handleSwitchAiContentShowPolicyPt3 = (val) => {
......@@ -439,16 +475,20 @@ const handleGetThinkPolicyIndustry = async () => {
box1Data.value = [];
return;
}
const data = list.map(item => ({
const data = list.map((item, idx) => ({
name: item.industry,
value: item.amount,
percent: item.percent
percent: item.percent,
color: getAreaTagColor(item.industry, idx)
}));
box1Data.value = data;
/* v-if 有数据后才挂载 #box1Chart,须等 DOM 更新后再 init echarts */
await nextTick();
const box1Chart = getPieChart(box1Data.value);
setChart(box1Chart, "box1Chart");
if (isShowAiContentPolicyPt1.value) {
fetchPolicyPtBox1ChartInterpretation();
}
} else {
box1Data.value = [];
}
......@@ -458,6 +498,30 @@ const handleGetThinkPolicyIndustry = async () => {
}
};
/** 政策建议关联法案:新标签页打开法案介绍页,billId 随接口 id 变化 */
const handleBillMoreClick = (bill) => {
const billId = bill?.id;
if (!billId) {
return;
}
const route = router.resolve({
path: "/billLayout/bill/introduction",
query: { billId: String(billId) }
});
window.open(route.href, "_blank");
};
/** 政策建议关联法案:新标签页打开政令介绍页*/
const handleAdministrativeMoreClick = (ad) => {
const id = ad?.bcId;
if (!id) {
return;
}
const route = router.resolve({
path: "/decreeLayout/overview/introduction",
query: { id: String(id) }
});
window.open(route.href, "_blank");
};
// 政策建议涉及部门分布(饼图)
const box2Data = ref([]);
......@@ -494,6 +558,9 @@ const handleGetPolicyAdviceDeptDistribution = async () => {
await nextTick();
const box2Chart = getPieChart(box2Data.value);
setChart(box2Chart, "box2Chart");
if (isShowAiContentPolicyPt2.value) {
fetchPolicyPtBox2ChartInterpretation();
}
} else {
box2Data.value = [];
}
......@@ -646,7 +713,8 @@ const handleGetThinkPolicyIndustryChange = async () => {
const industryAmount =
quarterData?.industryList?.find(i => i.industry === industry)?.amount || 0;
return industryAmount;
})
}),
color: getAreaTagColor(industry, frontendData.data.length)
};
frontendData.data.push(industryData);
});
......@@ -656,6 +724,9 @@ const handleGetThinkPolicyIndustryChange = async () => {
}
box3Data.value = frontendData;
await renderBox3Chart();
if (isShowAiContentPolicyPt3.value) {
fetchPolicyPtBox3ChartInterpretation();
}
} else {
box3Data.value = { title: [], data: [] };
}
......@@ -1231,14 +1302,14 @@ watch(
}
);
onMounted(() => {
handleGetThinkPolicyIndustry();
onMounted(async () => {
await handleGetThinkPolicyIndustry();
handleGetThinkPolicyIndustryTotal();
handleGetThinkPolicyIndustryChange();
await handleGetThinkPolicyIndustryChange();
handleGetHylyList();
handleGetGovAgencyList();
handleGetThinkPolicy();
handleGetPolicyAdviceDeptDistribution();
await handleGetPolicyAdviceDeptDistribution();
});
</script>
......@@ -1423,9 +1494,8 @@ onMounted(() => {
.source {
position: absolute;
bottom: 21px;
left: 50%;
transform: translateX(-50%);
width: 350px;
left: 24px;
width: 420px;
height: 22px;
display: flex;
}
......@@ -1453,7 +1523,7 @@ onMounted(() => {
width: 520px;
height: 372px;
box-sizing: border-box;
padding: 24px 24px 64px 24px;
padding: 0px 24px 64px 24px;
position: relative;
overflow: hidden;
......@@ -1481,15 +1551,13 @@ onMounted(() => {
flex: 1;
min-width: 0;
width: 472px;
height: 284px;
height: 308px;
}
.source {
position: absolute;
bottom: 21px;
left: 50%;
transform: translateX(-50%);
width: 350px;
bottom: 24px;
width: 420px;
height: 22px;
display: flex;
......
import * as echarts from 'echarts'
const colorList = [
// 按 AreaTag 的颜色规则映射到折线图配色(取 tag 的文字色)
const AREA_TAG_COLOR_BY_NAME = {
'人工智能': 'rgba(245, 34, 45, 1)', // tag1
'生物科技': 'rgba(19, 168, 168, 1)', // tag2
'新一代通信网络': 'rgba(5, 95, 194, 1)', // tag3
// 兼容常见写法
'通信网络': 'rgba(5, 95, 194, 1)',
'量子科技': 'rgba(114, 46, 209, 1)', // tag4
'新能源': 'rgba(82, 196, 26, 1)', // tag5
'集成电路': 'rgba(22, 119, 255, 1)', // tag6
'海洋': 'rgba(15, 120, 199, 1)', // tag7
'先进制造': 'rgba(250, 173, 20, 1)', // tag8
'新材料': 'rgba(250, 140, 22, 1)', // tag9
'航空航天': 'rgba(47, 84, 235, 1)', // tag10
'太空': 'rgba(47, 84, 235, 1)', // tag11
'深海': 'rgba(73, 104, 161, 1)', // tag12
'极地': 'rgba(133, 165, 255, 1)', // tag13
'核': 'rgba(250, 84, 28, 1)', // tag14
'其他': 'rgba(82, 196, 26, 1)' // tag15
}
const fallbackColorList = [
'rgba(5, 95, 194, 1)',
'rgba(245, 34, 45, 1)',
'rgba(19, 168, 168, 1)',
'rgba(250, 140, 22, 1)',
'rgba(114, 46, 209, 1)',
'rgba(115, 209, 61, 1)',
'rgba(206, 79, 81, 1)',
'rgba(145, 202, 255, 1)',
'rgba(95, 101, 108, 1)',
'rgba(82, 196, 26, 1)',
'rgba(22, 119, 255, 1)',
'rgba(250, 84, 28, 1)',
'rgba(47, 84, 235, 1)',
'rgba(64, 150, 255, 1)',
'rgba(34, 41, 52, 1)',
'rgba(173, 198, 255, 1)',
'rgba(255, 169, 64, 1)'
'rgba(133, 165, 255, 1)'
]
const parseRgba = (colorStr) => {
......@@ -50,7 +67,8 @@ const getMultiLineChart = (chartInput) => {
const echartsSeries = series.map((item, index) => {
const baseColor =
item.color ||
colorList[index % colorList.length] ||
AREA_TAG_COLOR_BY_NAME[item.name] ||
fallbackColorList[index % fallbackColorList.length] ||
`rgba(${Math.floor(Math.random() * 256)}, ${Math.floor(Math.random() * 256)}, ${Math.floor(Math.random() * 256)}, 1)`
const { r, g, b } = parseRgba(baseColor)
......@@ -58,9 +76,11 @@ const getMultiLineChart = (chartInput) => {
name: item.name,
type: 'line',
smooth: true,
lineStyle: { color: baseColor },
itemStyle: { color: baseColor },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: `rgba(${r}, ${g}, ${b}, 0.3)` },
{ offset: 0, color: `rgba(${r}, ${g}, ${b}, 0.1)` },
{ offset: 1, color: `rgba(${r}, ${g}, ${b}, 0)` }
])
},
......@@ -147,7 +167,7 @@ const getMultiLineChart = (chartInput) => {
itemHeight: 12
}
],
color: colorList,
// 不使用全局 color,改为每条 series 自己定色(与 AreaTag 一致)
xAxis: [
{
type: 'category',
......
const getPieChart = (data) => {
const seriesData = (Array.isArray(data) ? data : []).map((d) => {
const color = d?.color
if (!color) return d
return {
...d,
itemStyle: { ...(d.itemStyle || {}), color },
// “飞线”(labelLine)跟随领域色
labelLine: {
...(d.labelLine || {}),
lineStyle: { ...(d.labelLine?.lineStyle || {}), color }
}
}
})
let option = {
series: [
{
......@@ -57,7 +71,7 @@ const getPieChart = (data) => {
labelLinePoints: points
};
},
data: data
data: seriesData
}]
}
return option
......
......@@ -17,12 +17,12 @@
<AreaTag v-for="(tag, index) in thinkTank.tags" :key="index" :tagName="tag.industryName"></AreaTag>
</div>
</div>
<div class="header-top-right">
<!-- <div class="header-top-right">
<button class="blue-btn" @click="handleOpenThinkTankSite">
<img class="btn-img" src="./images/image1.png" alt="" />
<span class="text">{{ '查看智库官网' }}</span>
</button>
</div>
</div> -->
</div>
<div class="header-footer">
<div class="tab" :class="{ tabActive: tabActiveName === '智库动态' }" @click="switchTab('智库动态')">
......
......@@ -402,80 +402,8 @@ const handleSelectedFiltersUpdate = val => {
const author = ref('') // 作者
const curFooterList = ref([
// {
// title: "中国对AI的转型产业政策",
// time: "2025年6月26日",
// from: "兰德科技智库",
// img: Img1
// },
// {
// title: "中美对抗、竞争和合作跨越人工智能通用领域...",
// time: "2025年6月26日",
// from: "兰德科技智库",
// img: Img2
// },
// {
// title: "中国、智慧城市和中东:地区和美国的选择",
// time: "2025年6月26日",
// from: "兰德科技智库",
// img: Img3
// },
// {
// title: "中国对AI的转型产业政策",
// time: "2025年6月26日",
// from: "兰德科技智库",
// img: Img4
// },
// {
// title: "中美经济竞争:复杂经济和地缘政治关系中的...",
// time: "2025年6月26日",
// from: "兰德科技智库",
// img: Img5
// },
// {
// title: "中国、智慧城市和中东:留给地区和美国的选择",
// time: "2025年6月26日",
// from: "兰德科技智库",
// img: Img6
// },
// {
// title: "中国对AI的转型产业政策",
// time: "2025年6月26日",
// from: "兰德科技智库",
// img: Img7
// },
// {
// title: "中美对抗、竞争和合作跨越人工智能通用领域...",
// time: "2025年6月26日",
// from: "",
// img: Img8
// },
// {
// title: "中国、智慧城市和中东:地区和美国的选择",
// time: "2025年6月26日",
// from: "兰德科技智库",
// img: Img9
// },
// {
// title: "中国对AI的转型产业政策",
// time: "2025年6月26日",
// from: "兰德科技智库",
// img: Img10
// },
// {
// title: "中美对抗、竞争和合作跨越人工智能通用领域...",
// time: "2025年6月26日",
// from: "兰德科技智库",
// img: Img11
// },
// {
// title: "中国、智慧城市和中东:地区和美国的选择",
// time: "2025年6月26日",
// from: "兰德科技智库",
// img: Img12
// }
]);
/** 智库报告 / 调查项目共用列表:与资源库一致,初始为空,接口非成功时清空 */
const curFooterList = ref([]);
......@@ -579,11 +507,10 @@ const handleGetThinkDynamicsReport = async (payload) => {
params.keyword = keyword;
}
const res = await getThinkTankReport(params);
console.log("智库动态报告", res);
if (res.code === 200 && res.data) {
curFooterList.value = res.data.content;
total.value = res.data.totalElements;
} else if (res.code === 500 && res.message === "未找到对应筛选的报告") {
} else {
curFooterList.value = [];
total.value = 0;
}
......
......@@ -453,9 +453,9 @@ const handleGetThinkTankFundsSource = async () => {
alignTo: 'edge',
offset: [0, -LABEL_OFFSET_UP],
formatter(params) {
const valueYi = (params.data.value || 0) / 100000000
const valueYi = (params.data.value || 0) / 10000
const percent = params.percent || 0
const valueStr = `${valueYi.toFixed(3)}亿 ${percent}%`
const valueStr = `${valueYi.toFixed(2)} ${percent}%`
let cumulative = 0
for (let i = 0; i < params.dataIndex; i++) cumulative += dataList[i].value || 0
const centerAngle = 90 + ((cumulative + (params.data.value || 0) / 2) / total) * 360
......@@ -1096,10 +1096,10 @@ onMounted(() => {
}
.source {
margin: 0 auto;
margin-top: 10px;
margin-left: 240px;
/* 上下0,左右自动居中 */
width: 370px;
height: 22px;
display: flex;
......@@ -1285,7 +1285,7 @@ onMounted(() => {
}
.source {
margin: 0 auto;
margin-left: 35px;
margin-top: 10px;
/* 上下0,左右自动居中 */
......
......@@ -7,18 +7,11 @@
<div class="title">{{ "科技领域" }}</div>
</div>
<div class="select-main">
<el-checkbox-group
class="checkbox-group"
:model-value="selectedTypeIds"
@change="handleAreaGroupChange">
<el-checkbox-group class="checkbox-group" :model-value="selectedTypeIds" @change="handleAreaGroupChange">
<el-checkbox class="filter-checkbox" :label="RESOURCE_FILTER_ALL_AREA">
{{ RESOURCE_FILTER_ALL_AREA }}
</el-checkbox>
<el-checkbox
class="filter-checkbox"
v-for="t in (researchTypeList || [])"
:key="t.id"
:label="t.id">
<el-checkbox class="filter-checkbox" v-for="t in (researchTypeList || [])" :key="t.id" :label="t.id">
{{ t.name }}
</el-checkbox>
</el-checkbox-group>
......@@ -31,18 +24,11 @@
<div class="title">{{ "发布时间" }}</div>
</div>
<div class="select-main">
<el-checkbox-group
class="checkbox-group"
:model-value="selectedYearIds"
@change="handleYearGroupChange">
<el-checkbox-group class="checkbox-group" :model-value="selectedYearIds" @change="handleYearGroupChange">
<el-checkbox class="filter-checkbox" :label="RESOURCE_FILTER_ALL_TIME">
{{ RESOURCE_FILTER_ALL_TIME }}
</el-checkbox>
<el-checkbox
class="filter-checkbox"
v-for="y in (researchTimeList || [])"
:key="y.id"
:label="y.id">
<el-checkbox class="filter-checkbox" v-for="y in (researchTimeList || [])" :key="y.id" :label="y.id">
{{ y.name }}
</el-checkbox>
</el-checkbox-group>
......@@ -71,17 +57,17 @@
</div>
<div class="file-box">
<div class="file">
<div class="file" v-for="sv in item.billInfoList" :key="sv.id || sv.name">
<div class="type">法案</div>
<div class="title">{{ item.billInfoList[0].name }}</div>
<div class="more">
<div class="title">{{ sv.name }}</div>
<div class="more" @click.stop="handleBillMoreClick(sv)">
<img src="../assets/images/image-right.png" alt="" />
</div>
</div>
<div class="file">
<div class="file" v-for="(sv, index) in item.administrativeOrderInfoVOList" :key="index">
<div class="type">政令</div>
<div class="title">{{ item.billInfoList[0].name }}</div>
<div class="more">
<div class="title">{{ sv.content }}</div>
<div class="more" @click.stop="handleAdministrativeMoreClick(sv)">
<img src="../assets/images/image-right.png" alt="" />
</div>
</div>
......@@ -106,6 +92,7 @@
<script setup>
import { ref } from "vue";
import { useRouter } from "vue-router";
import AreaTag from "@/components/base/AreaTag/index.vue";
import {
RESOURCE_FILTER_ALL_AREA,
......@@ -126,6 +113,32 @@ defineProps({
const emit = defineEmits(["filter-change", "page-change", "item-click"]);
const router = useRouter();
/** 政策建议关联法案:新标签页打开法案介绍页,billId 随接口 id 变化 */
const handleBillMoreClick = (bill) => {
const billId = bill?.id;
if (!billId) {
return;
}
const route = router.resolve({
path: "/billLayout/bill/introduction",
query: { billId: String(billId) }
});
window.open(route.href, "_blank");
};
/** 政策建议关联法案:新标签页打开政令介绍页*/
const handleAdministrativeMoreClick = (ad) => {
const id = ad?.bcId;
if (!id) {
return;
}
const route = router.resolve({
path: "/decreeLayout/overview/introduction",
query: { id: String(id) }
});
window.open(route.href, "_blank");
};
const selectedTypeIds = ref([RESOURCE_FILTER_ALL_AREA]);
const selectedYearIds = ref([RESOURCE_FILTER_ALL_TIME]);
......@@ -428,6 +441,7 @@ const handleYearGroupChange = (val) => {
height: 20px;
display: flex;
margin-top: 2px;
cursor: pointer;
.img {
width: 100%;
......
......@@ -163,8 +163,8 @@
</div>
<DivideHeader id="position2" class="divide-header" :titleText="'资讯要闻'"></DivideHeader>
<div class="center-center">
<NewsList :newsList="newsList" @item-click="handleToNewsAnalysis" @more-click="handleToMoreNews"
img="newsImage" title="newsTitle" content="newsContent" from="from" />
<NewsList :newsList="newsList" @item-click="item => gotoNewsDetail(item.newsId)"
@more-click="handleToMoreNews" img="newsImage" title="newsTitle" content="newsContent" from="from" />
<MessageBubble :messageList="messageList" imageUrl="personImage" @more-click="handleToSocialDetail"
@person-click="handleClickPerson" name="personName" content="remarks" source="orgName" />
</div>
......@@ -444,29 +444,18 @@ import Logo3 from "./assets/images/logo3.png";
import Logo4 from "./assets/images/logo4.png";
import Logo5 from "./assets/images/logo5.png";
import Img1 from "./assets/images/img1.png";
import Img2 from "./assets/images/img2.png";
import Img3 from "./assets/images/img3.png";
import Img4 from "./assets/images/img4.png";
import Img5 from "./assets/images/img5.png";
import Img6 from "./assets/images/img6.png";
import Img7 from "./assets/images/img7.png";
import Img8 from "./assets/images/img8.png";
import Img9 from "./assets/images/img9.png";
import Img10 from "./assets/images/img10.png";
import Img11 from "./assets/images/img11.png";
import Img12 from "./assets/images/img12.png";
import Box1Img from "./assets/images/box1-img.png";
import Box1Logo from "./assets/images/box1-logo.png";
import { setCanvasCreator } from "echarts/core";
import { ElMessage } from "element-plus";
import { useRouter } from 'vue-router';
import { useGotoNewsDetail } from '@/router/modules/news';
const gotoNewsDetail = useGotoNewsDetail()
const containerRef = ref(null);
const statCountInfo = ref([]);
const pageSize = ref(15)
const totalAllItem = ref(0)
const isShowAiContentBox5 = ref(false);
const isShowAiContentBox5 = ref(true);
const aiContentBox5 = ref("");
const isBox5InterpretLoading = ref(false);
const handleSwitchAiContentShowBox5 = (val) => {
......@@ -475,7 +464,8 @@ const handleSwitchAiContentShowBox5 = (val) => {
fetchBox5ChartInterpretation();
}
};
const isShowAiContentBox6 = ref(false);
// 刷新后默认展示「领域分布情况」AI 总结
const isShowAiContentBox6 = ref(true);
const aiContentBox6 = ref("");
const isBox6InterpretLoading = ref(false);
const handleSwitchAiContentShowBox6 = (val) => {
......@@ -484,7 +474,8 @@ const handleSwitchAiContentShowBox6 = (val) => {
fetchBox6ChartInterpretation();
}
};
const isShowAiContentBox7 = ref(false);
// 刷新后默认展示「智库资金流向」AI 总结
const isShowAiContentBox7 = ref(true);
const aiContentBox7 = ref("");
const isBox7InterpretLoading = ref(false);
const handleSwitchAiContentShowBox7 = (val) => {
......@@ -1017,6 +1008,10 @@ const renderBox5Chart = () => {
const handleBox5AreaChange = () => {
aiContentBox5.value = "";
renderBox5Chart();
// 切换领域后,若 AI 面板已打开则重新触发流式解读
if (isShowAiContentBox5.value) {
fetchBox5ChartInterpretation();
}
};
const handleBox5 = async year => {
......@@ -1025,7 +1020,13 @@ const handleBox5 = async year => {
box5selectetedArea.value = "全部领域";
await handleGetThinkTankPolicyIndustryChange(getBox5YearDateRange(y));
renderBox5Chart();
aiContentBox5.value = "";
// 若 AI 面板已打开,让解读在首次加载时自动生成;否则仅清空缓存
if (isShowAiContentBox5.value) {
aiContentBox5.value = "";
fetchBox5ChartInterpretation();
} else {
aiContentBox5.value = "";
}
};
/** 兼容 getChartAnalysis 返回对象:从 data[0] 提取「解读」文本 */
......@@ -1247,14 +1248,45 @@ const box6TankList = ref([
}
]);
function transformToChartFormat(data) {
// 预设颜色池(可按需修改或扩展)
const colorPalette = ["#4096FF", "#FFA39E", "#ADC6FF", "#FFC069", "#B5F5EC", "#B37FEB", "#D6E4FF", "#FF8C8C", "#87E8DE"];
// 按 AreaTag 的颜色规则映射到饼图配色(取 tag 的文字色)
const areaTagColorByName = {
"人工智能": "rgba(245, 34, 45, 1)", // tag1
"生物科技": "rgba(19, 168, 168, 1)", // tag2
"新一代通信网络": "rgba(5, 95, 194, 1)", // tag3
// 兼容常见写法
"通信网络": "rgba(5, 95, 194, 1)",
"量子科技": "rgba(114, 46, 209, 1)", // tag4
"新能源": "rgba(82, 196, 26, 1)", // tag5
"集成电路": "rgba(22, 119, 255, 1)", // tag6
"海洋": "rgba(15, 120, 199, 1)", // tag7
"先进制造": "rgba(250, 173, 20, 1)", // tag8
"新材料": "rgba(250, 140, 22, 1)", // tag9
"航空航天": "rgba(47, 84, 235, 1)", // tag10
"太空": "rgba(47, 84, 235, 1)", // tag11
"深海": "rgba(73, 104, 161, 1)", // tag12
"极地": "rgba(133, 165, 255, 1)", // tag13
"核": "rgba(250, 84, 28, 1)", // tag14
"其他": "rgba(82, 196, 26, 1)" // tag15
};
// 未命中 AreaTag 映射时的兜底色板
const fallbackColorPalette = [
"rgba(5, 95, 194, 1)",
"rgba(245, 34, 45, 1)",
"rgba(19, 168, 168, 1)",
"rgba(250, 140, 22, 1)",
"rgba(114, 46, 209, 1)",
"rgba(82, 196, 26, 1)",
"rgba(22, 119, 255, 1)",
"rgba(250, 84, 28, 1)",
"rgba(47, 84, 235, 1)"
];
const list = Array.isArray(data) ? data.slice(0, 7) : [];
return list.map((item, index) => ({
name: item.industry,
value: item.amount,
color: colorPalette[index % colorPalette.length]
color: areaTagColorByName[item.industry] || fallbackColorPalette[index % fallbackColorPalette.length]
}));
}
// 政策建议领域分布
......@@ -1300,6 +1332,10 @@ const handleBox6 = async () => {
aiContentBox6.value = "";
await handleGetThinkTankPolicyIndustry();
renderBox6Chart();
// 若 AI 面板已打开,让解读在首次加载时自动生成
if (isShowAiContentBox6.value) {
fetchBox6ChartInterpretation();
}
};
// 智库资金流向
......@@ -1411,6 +1447,10 @@ const handleBox7 = async () => {
const links = box7Data.value?.links ?? [];
const box7Chart = getSankeyChart(nodes, links);
setChart(box7Chart, "box7Chart");
// 若 AI 面板已打开,让解读在首次加载时自动生成
if (isShowAiContentBox7.value) {
fetchBox7ChartInterpretation();
}
};
/** 请求 box7 智库资金流向桑基图解读(入参:{ text: JSON.stringify({ type, name, nodes, links }) }) */
......@@ -1733,80 +1773,8 @@ const toggleResourceLibrarySortPrefix = () => {
}
};
const curFooterList = ref([
{
title: "中国对AI的转型产业政策",
time: "2025年6月26日",
from: "兰德科技智库",
img: Img1
},
{
title: "中美对抗、竞争和合作跨越人工智能通用领域...",
time: "2025年6月26日",
from: "兰德科技智库",
img: Img2
},
{
title: "中国、智慧城市和中东:地区和美国的选择",
time: "2025年6月26日",
from: "兰德科技智库",
img: Img3
},
{
title: "中国对AI的转型产业政策",
time: "2025年6月26日",
from: "兰德科技智库",
img: Img4
},
{
title: "中美经济竞争:复杂经济和地缘政治关系中的...",
time: "2025年6月26日",
from: "兰德科技智库",
img: Img5
},
{
title: "中国、智慧城市和中东:留给地区和美国的选择",
time: "2025年6月26日",
from: "兰德科技智库",
img: Img6
},
{
title: "中国对AI的转型产业政策",
time: "2025年6月26日",
from: "兰德科技智库",
img: Img7
},
{
title: "中美对抗、竞争和合作跨越人工智能通用领域...",
time: "2025年6月26日",
from: "",
img: Img8
},
{
title: "中国、智慧城市和中东:地区和美国的选择",
time: "2025年6月26日",
from: "兰德科技智库",
img: Img9
},
{
title: "中国对AI的转型产业政策",
time: "2025年6月26日",
from: "兰德科技智库",
img: Img10
},
{
title: "中美对抗、竞争和合作跨越人工智能通用领域...",
time: "2025年6月26日",
from: "兰德科技智库",
img: Img11
},
{
title: "中国、智慧城市和中东:地区和美国的选择",
time: "2025年6月26日",
from: "兰德科技智库",
img: Img12
}
]);
/** 与调查项目 surveyFooterList 一致:初始空列表,由接口填充;失败或非 200 时清空 */
const curFooterList = ref([]);
const currentPage = ref(1);
const total = ref(0);
......@@ -1886,40 +1854,6 @@ const handlePolicyCurrentChange = page => {
};
const handleGetThinkTankPolicyAdvice = async () => {
const mockPolicyAdviceList = () => ([
{
id: 23702,
content: "研究未来AI数据中心电力需求与供应",
imageUrl: "https://www.rand.org/content/rand/pubs/research_reports/RRA3572-1/jcr:content/par/teaser.crop.1200x900.cm.jpeg/1738160125961.jpeg",
name: "指数级增长下AI的电力需求:外推AI数据中心的电力需求并评估其对美国竞争力的潜在影响",
reportId: "Rand_RRA3572-1",
tagList: ["新能源", "人工智能"],
thinkTankName: null,
times: "2025-12-28"
},
{ id: 23703, content: "构建可信任的AI治理框架:监管与创新的平衡", imageUrl: "https://www.rand.org/content/rand/pubs/research_reports/RRA3572-1/jcr:content/par/teaser.crop.1200x900.cm.jpeg/1738160125961.jpeg", name: "AI治理路线图:从风险评估到合规落地的实践建议", reportId: "Rand_RRA3572-2", tagList: ["人工智能", "网络安全"], thinkTankName: null, times: "2025-12-21" },
{ id: 23704, content: "先进制造供应链韧性评估与关键节点识别", imageUrl: "https://www.rand.org/content/rand/pubs/research_reports/RRA3572-1/jcr:content/par/teaser.crop.1200x900.cm.jpeg/1738160125961.jpeg", name: "供应链韧性:关键材料、关键设备与关键人才的系统性策略", reportId: "Rand_RRA3572-3", tagList: ["先进制造", "新材料"], thinkTankName: null, times: "2025-12-14" },
{ id: 23705, content: "半导体出口管制对产业生态的中长期影响", imageUrl: "https://www.rand.org/content/rand/pubs/research_reports/RRA3572-1/jcr:content/par/teaser.crop.1200x900.cm.jpeg/1738160125961.jpeg", name: "出口管制与产业政策:如何降低对关键环节的外部依赖", reportId: "Rand_RRA3572-4", tagList: ["集成电路", "先进制造"], thinkTankName: null, times: "2025-12-07" },
{ id: 23706, content: "量子信息技术发展态势与应用落地路径", imageUrl: "https://www.rand.org/content/rand/pubs/research_reports/RRA3572-1/jcr:content/par/teaser.crop.1200x900.cm.jpeg/1738160125961.jpeg", name: "量子科技:从基础研究到产业化的阶段性里程碑", reportId: "Rand_RRA3572-5", tagList: ["量子科技", "前沿基础"], thinkTankName: null, times: "2025-11-30" },
{ id: 23707, content: "网络安全威胁图谱:关键基础设施的攻击面", imageUrl: "https://www.rand.org/content/rand/pubs/research_reports/RRA3572-1/jcr:content/par/teaser.crop.1200x900.cm.jpeg/1738160125961.jpeg", name: "关键基础设施安全:供应链、身份与数据的三重防护", reportId: "Rand_RRA3572-6", tagList: ["网络安全", "数据安全"], thinkTankName: null, times: "2025-11-23" },
{ id: 23708, content: "生物技术与医疗创新的监管协同机制", imageUrl: "https://www.rand.org/content/rand/pubs/research_reports/RRA3572-1/jcr:content/par/teaser.crop.1200x900.cm.jpeg/1738160125961.jpeg", name: "生物科技创新:试验、审批与产业化的全流程政策工具箱", reportId: "Rand_RRA3572-7", tagList: ["生物科技", "医疗健康"], thinkTankName: null, times: "2025-11-16" },
{ id: 23709, content: "清洁能源转型下的电网规划与储能配置", imageUrl: "https://www.rand.org/content/rand/pubs/research_reports/RRA3572-1/jcr:content/par/teaser.crop.1200x900.cm.jpeg/1738160125961.jpeg", name: "能源系统转型:电网升级、储能布局与成本测算", reportId: "Rand_RRA3572-8", tagList: ["新能源", "储能"], thinkTankName: null, times: "2025-11-09" },
{ id: 23710, content: "新材料在国防与先进制造中的应用瓶颈", imageUrl: "https://www.rand.org/content/rand/pubs/research_reports/RRA3572-1/jcr:content/par/teaser.crop.1200x900.cm.jpeg/1738160125961.jpeg", name: "关键新材料:验证体系、规模化与供应链安全", reportId: "Rand_RRA3572-9", tagList: ["新材料", "先进制造"], thinkTankName: null, times: "2025-11-02" },
{ id: 23711, content: "通信网络演进:6G 与卫星互联网的融合", imageUrl: "https://www.rand.org/content/rand/pubs/research_reports/RRA3572-1/jcr:content/par/teaser.crop.1200x900.cm.jpeg/1738160125961.jpeg", name: "下一代通信:频谱、标准与产业联盟策略", reportId: "Rand_RRA3572-10", tagList: ["通信网络", "卫星互联网"], thinkTankName: null, times: "2025-10-26" },
{ id: 23712, content: "数据要素市场建设与跨境数据合规路径", imageUrl: "https://www.rand.org/content/rand/pubs/research_reports/RRA3572-1/jcr:content/par/teaser.crop.1200x900.cm.jpeg/1738160125961.jpeg", name: "数据治理与合规:分类分级、出境评估与执法协同", reportId: "Rand_RRA3572-11", tagList: ["数据安全", "数字治理"], thinkTankName: null, times: "2025-10-19" },
{ id: 23713, content: "AI 研发人才供需缺口与教育体系对接", imageUrl: "https://www.rand.org/content/rand/pubs/research_reports/RRA3572-1/jcr:content/par/teaser.crop.1200x900.cm.jpeg/1738160125961.jpeg", name: "AI 人才战略:培养、引进与留用的组合政策", reportId: "Rand_RRA3572-12", tagList: ["人工智能", "人才政策"], thinkTankName: null, times: "2025-10-12" },
{ id: 23714, content: "自动驾驶安全监管与测试评价体系", imageUrl: "https://www.rand.org/content/rand/pubs/research_reports/RRA3572-1/jcr:content/par/teaser.crop.1200x900.cm.jpeg/1738160125961.jpeg", name: "自动驾驶:安全标准、责任划分与道路试点机制", reportId: "Rand_RRA3572-13", tagList: ["智能网联", "交通出行"], thinkTankName: null, times: "2025-10-05" },
{ id: 23715, content: "关键基础设施数字化升级的投融资机制", imageUrl: "https://www.rand.org/content/rand/pubs/research_reports/RRA3572-1/jcr:content/par/teaser.crop.1200x900.cm.jpeg/1738160125961.jpeg", name: "基础设施升级:公共资金撬动与绩效评估体系", reportId: "Rand_RRA3572-14", tagList: ["数字化转型", "基础设施"], thinkTankName: null, times: "2025-09-28" }
]);
const applyMockPolicyAdvice = () => {
const mock = mockPolicyAdviceList();
policyTotal.value = mock.length;
const pageSize = 7;
const page = Math.max(1, Number(policyCurrentPage.value) || 1);
const start = (page - 1) * pageSize;
policyFooterList.value = mock.slice(start, start + pageSize);
};
const strippedPolicyYears = stripAllTimeForRequest(policySelectedYearIds.value);
const allPubTimeIds = (pubTimeList.value || []).map((x) => x.id);
/** 与智库报告一致:仅「全部时间」或选满所有具体年份 → 不按 years 狭义过滤 */
......@@ -1933,7 +1867,7 @@ const handleGetThinkTankPolicyAdvice = async () => {
: (areaList.value || []).map(obj => obj.id);
const domainIdsStr = arrayToString(effectiveDomainIds);
const params = {
currentPage: policyCurrentPage.value - 1,
pageNum: policyCurrentPage.value,
pageSize: 7,
sortFun: policySort.value === true
};
......@@ -1966,19 +1900,20 @@ const handleGetThinkTankPolicyAdvice = async () => {
administrativeOrderInfoVOList: item.administrativeOrderInfoVOList
}));
if (!mapped.length) {
applyMockPolicyAdvice();
return;
}
policyFooterList.value = mapped;
policyTotal.value = res.data.totalElements || mapped.length;
policyTotal.value = res.data.totalElements ?? mapped.length;
} else {
applyMockPolicyAdvice();
policyFooterList.value = [];
policyTotal.value = 0;
if (res?.message) {
ElMessage.warning(res.message);
}
}
} catch (error) {
applyMockPolicyAdvice();
policyFooterList.value = [];
policyTotal.value = 0;
console.error("获取政策建议 error", error);
ElMessage.warning("获取政策建议失败");
}
};
// 处理页码改变事件
......@@ -2019,10 +1954,12 @@ const handleGetetThinkTankReport = async () => {
};
try {
const res = await getThinkTankReport(params);
console.log("智库报告", res);
if (res.code === 200 && res.data) {
curFooterList.value = res.data.content;
total.value = res.data.totalElements;
} else {
curFooterList.value = [];
total.value = 0;
}
} catch (error) {
console.error("获取智库报告error", error);
......@@ -2160,9 +2097,12 @@ onMounted(async () => {
handleGetNewReport();
handleGetThinkTankRiskSignal();
handleBox5(box5selectetedYear.value);
handleBox6();
handleBox7();
// 先拉到图表数据,再打开 AI 面板并触发解读,避免初始为空导致“无内容”
await handleBox5(box5selectetedYear.value);
handleSwitchAiContentShowBox5(true);
// 先把图表数据准备好,避免用户悬浮太快触发解读但数据未就绪
await handleBox6();
await handleBox7();
handleGetHylyList();
handleGetThinkTankHot(getDateYearsAgo(1));
handleGetetThinkTankReport();
......@@ -3540,7 +3480,7 @@ onMounted(async () => {
width: 1063px;
box-sizing: border-box;
position: relative;
padding: 24px 24px 64px 24px;
padding: 0px 24px 64px 24px;
&.box5-main--empty {
display: flex;
......@@ -3569,19 +3509,17 @@ onMounted(async () => {
.box5-chart-canvas {
flex: 1;
min-width: 0;
height: 324px;
height: 348px;
}
.source {
position: absolute;
bottom: 21px;
left: 50%;
/* 让元素左边对齐父级中线 */
transform: translateX(-50%);
left: 24px;
/* 上下0,左右自动居中 */
width: 370px;
height: 22px;
display: flex;
......@@ -3716,13 +3654,11 @@ onMounted(async () => {
position: absolute;
bottom: 21px;
left: 50%;
/* 让元素左边对齐父级中线 */
transform: translateX(-50%);
left: 24px;
/* 让元素自身向左回退一半宽度 */
/* 上下0,左右自动居中 */
width: 370px;
height: 22px;
display: flex;
......@@ -3895,9 +3831,7 @@ onMounted(async () => {
position: absolute;
bottom: 21px;
left: 50%;
/* 让元素左边对齐父级中线 */
transform: translateX(-50%);
left: 24px;
/* 上下0,左右自动居中 */
......
......@@ -24,11 +24,24 @@
</div>
<div class="main">
<div class="main-header">
<div style=" margin-top: 17px;">
<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>
......@@ -48,17 +61,24 @@
</div>
</div>
<div class="report-box">
<pdf v-if="valueSwitch && reportUrlEnWithPage" ref="leftPdfRef" :pdfUrl="reportUrlEnWithPage"
class="pdf-pane" />
<pdf v-if="reportUrlWithPage" ref="rightPdfRef" :pdfUrl="reportUrlWithPage"
:class="['pdf-pane', { 'pdf-pane-full': !valueSwitch }]" />
<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>
</div>
</template>
<script setup>
import { computed, ref, onMounted } from "vue";
import { computed, ref, onMounted, watch } from "vue";
import pdf from "./pdf.vue";
import {
getThinkTankReportSummary,
......@@ -88,14 +108,59 @@ const buildPdfPageUrl = url => {
const reportUrlWithPage = computed(() => buildPdfPageUrl(reportUrl.value))
const reportUrlEnWithPage = computed(() => buildPdfPageUrl(reportUrlEn.value))
const valueSwitch = ref(true)
const showSearchInput = ref(false)
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 updateMatchInfo = () => {
const pdf = activePdfRef.value
if (pdf && typeof pdf.getMatchInfo === 'function') {
matchInfo.value = pdf.getMatchInfo()
return
}
matchInfo.value = { current: 0, total: 0 }
}
watch(
() => searchKeywordText.value,
(val) => {
const keyword = String(val ?? '').trim()
if (!keyword) {
clearPdfSearchState()
}
}
)
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
......@@ -110,6 +175,8 @@ const handleSearchInPdf = async () => {
}
if (page && targetRef && typeof targetRef.goToPage === 'function') {
targetRef.goToPage(page)
activePdfRef.value = targetRef
updateMatchInfo()
} else {
try {
const { ElMessage } = await import('element-plus')
......@@ -118,6 +185,20 @@ const handleSearchInPdf = async () => {
}
}
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, {
......@@ -410,11 +491,14 @@ onMounted(async () => {
text-align: left;
display: flex;
justify-content: space-between;
align-items: center;
overflow: visible;
.btn-box {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
.translate {
display: flex;
......@@ -422,6 +506,7 @@ onMounted(async () => {
align-items: center;
height: 24px;
margin-right: 16px;
flex-shrink: 0;
......@@ -522,6 +607,58 @@ onMounted(async () => {
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;
}
.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;
cursor: pointer;
padding: 0;
flex-shrink: 0;
white-space: nowrap;
}
.search-nav-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
}
......@@ -536,13 +673,21 @@ onMounted(async () => {
overflow-x: hidden;
}
.pdf-pane {
width: 50%;
.pdf-pane-wrap {
flex: 0 0 50%;
max-width: 50%;
height: 100%;
min-width: 0;
}
.pdf-pane-full {
.pdf-pane-wrap.is-full {
flex: 0 0 100%;
max-width: 100%;
}
.pdf-pane-inner {
width: 100%;
height: 100%;
}
}
}
......
<template>
<div class="pdf-viewer">
<canvas
v-for="page in pageCount"
:key="page"
:ref="el => setCanvasRef(page, el)"
></canvas>
<!-- 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, onMounted, nextTick } from 'vue';
import { ref, shallowRef, nextTick, watch } from 'vue';
import * as pdfjsLib from 'pdfjs-dist/build/pdf.mjs';
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url';
// 通过 Vite 的 ?url 产出静态资源地址,确保线上/线下都能正确加载 worker
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
export default {
name: 'PdfViewer',
props: {
......@@ -25,98 +26,293 @@ export default {
}
},
setup(props) {
// 非响应式的 canvas 映射,避免触发布局递归更新
const canvasMap = {};
const overlayMap = {};
const pageCount = ref(0);
const loading = ref(true);
const pdfDocRef = ref(null);
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 字段,
// 若被 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
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;
}
};
// 清空所有高亮(不销毁 textLayer)
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(canvas + textLayer)
const renderPage = async (pdf, pageNum) => {
const pdfPage = await pdf.getPage(pageNum);
const canvas = canvasMap[pageNum];
const textLayer = overlayMap[pageNum];
if (!canvas || !textLayer) return;
// 以画布的可视宽度为基准自适应缩放,避免 CSS 强行拉伸导致 textLayer/高亮错位
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 不再被 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');
pdfjsApiRef.value = legacy;
api = legacy;
rt = legacy?.renderTextLayer;
} catch (_) { }
}
if (typeof rt === 'function') {
await rt({
textContent,
container: textLayer,
viewport,
// pdfjs 3.x 需要传入 textDivs 数组
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
const url = parsePdfUrl(pdfUrl);
if (!url) return;
loading.value = true;
pdfDocRef.value = null;
clearHighlights();
matchList.value = [];
searchKey.value = '';
renderedPageCount.value = 0;
resolveRenderAll = null;
loading.value = true
pdfDocRef.value = null
try {
const loadingTask = pdfjsLib.getDocument(url);
const pdf = await loadingTask.promise;
pdfDocRef.value = pdf
const pdf = await pdfjsLib.getDocument(url).promise;
pdfDocRef.value = pdf;
pageCount.value = pdf.numPages;
// 等待 canvas 按 pageCount 渲染出来
await nextTick();
for (let p = 1; p <= pdf.numPages; p++) {
const pdfPage = await pdf.getPage(p);
const viewport = pdfPage.getViewport({ scale: 1.5 });
const canvas = canvasMap[p];
if (!canvas) continue;
const context = canvas.getContext('2d');
const renderContext = {
canvasContext: context,
viewport: viewport
};
canvas.width = viewport.width;
canvas.height = viewport.height;
await pdfPage.render(renderContext).promise;
await renderPage(pdf, p);
}
} catch (error) {
console.error('加载 PDF 出错:', error);
} catch (err) {
console.error('PDF 加载失败', err);
} finally {
loading.value = false;
}
}
};
/** 在 PDF 中查找关键词,返回首次出现的页码(1-based),未找到返回 0 */
const searchKeyword = async (keyword) => {
const doc = pdfDocRef.value
if (!doc || !keyword || !String(keyword).trim()) return 0
const k = String(keyword).trim()
const num = doc.numPages
for (let p = 1; p <= num; p++) {
const page = await doc.getPage(p)
const content = await page.getTextContent()
const text = (content.items || []).map(it => it.str || '').join('')
if (text.includes(k)) return p
// 搜索关键词 + 高亮(记录每个命中的子串范围)
const doSearch = async () => {
const doc = pdfDocRef.value;
const key = searchKey.value.trim();
clearHighlights();
matchList.value = [];
matchIdx.value = 0;
if (!doc || !key) return;
// 首次搜索时确保所有页的 textLayer 已渲染完成,避免“越搜越多”
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);
}
}
}
return 0
}
/** 滚动到指定页码(1-based)对应的 canvas */
const goToPage = (pageNum) => {
const canvas = canvasMap[pageNum]
if (canvas && typeof canvas.scrollIntoView === 'function') {
canvas.scrollIntoView({ behavior: 'smooth', block: 'start' })
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();
// 用 Range 精确计算“子串”在页面上的矩形位置,再画黄色块,避免把整段 span 都标黄
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
}
}
}
// 首次挂载后再根据当前 url 渲染,避免 canvas 还没准备好
onMounted(() => {
if (props.pdfUrl) {
renderPdf(props.pdfUrl)
// 优先只滚动右侧 report-box,避免触发整页滚动导致 header 遮挡
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' });
}
})
};
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
}
};
}
};
</script>
......@@ -125,7 +321,12 @@ export default {
.pdf-viewer {
position: relative;
width: 100%;
/* 高度由内容决定,让外层容器控制滚动 */
}
.page-wrap {
position: relative;
margin-bottom: 16px;
width: 100%;
}
canvas {
......@@ -134,6 +335,44 @@ canvas {
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;
/* pdf.js v5 TextLayer:用变量计算真实字形盒子尺寸,否则背景宽高会不准 */
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%;
......
......@@ -13,23 +13,40 @@ const getMultiLineChart = (data) => {
const legendFirstLine = allNames.slice(0, legendSplitAt)
const legendSecondLine = allNames.slice(legendSplitAt)
// 定义配色数组
const colorList = [
'rgba(5, 95, 194, 1)', // #055fc2
'rgba(19, 168, 168, 1)', // #13a8a8
'rgba(250, 140, 22, 1)', // #fa8c16
'rgba(114, 46, 209, 1)', // #722ed1
'rgba(115, 209, 61, 1)', // #73d13d
'rgba(206, 79, 81, 1)', // #ce4f51
'rgba(145, 202, 255, 1)', // #91caff
'rgba(95, 101, 108, 1)', // #5f656c
'rgba(250, 84, 28, 1)', // #fa541c
'rgba(47, 84, 235, 1)', // #2f54eb
'rgba(64, 150, 255, 1)', // #4096ff
'rgba(34, 41, 52, 1)', // #222934
'rgba(173, 198, 255, 1)', // #adc6ff
'rgba(255, 169, 64, 1)' // #ffa940
];
// 按 AreaTag 的颜色规则映射到折线图配色(取 tag 的文字色)
const AREA_TAG_COLOR_BY_NAME = {
'人工智能': 'rgba(245, 34, 45, 1)', // tag1
'生物科技': 'rgba(19, 168, 168, 1)', // tag2
'新一代通信网络': 'rgba(5, 95, 194, 1)', // tag3
// 兼容后端/页面常见写法
'通信网络': 'rgba(5, 95, 194, 1)',
'量子科技': 'rgba(114, 46, 209, 1)', // tag4
'新能源': 'rgba(82, 196, 26, 1)', // tag5
'集成电路': 'rgba(22, 119, 255, 1)', // tag6
'海洋': 'rgba(15, 120, 199, 1)', // tag7
'先进制造': 'rgba(250, 173, 20, 1)', // tag8
'新材料': 'rgba(250, 140, 22, 1)', // tag9
'航空航天': 'rgba(47, 84, 235, 1)', // tag10
'太空': 'rgba(47, 84, 235, 1)', // tag11
'深海': 'rgba(73, 104, 161, 1)', // tag12
'极地': 'rgba(133, 165, 255, 1)', // tag13
'核': 'rgba(250, 84, 28, 1)', // tag14
'其他': 'rgba(82, 196, 26, 1)' // tag15
}
// 兜底颜色池(未命中 AreaTag 映射时使用)
const fallbackColorList = [
'rgba(5, 95, 194, 1)',
'rgba(19, 168, 168, 1)',
'rgba(250, 140, 22, 1)',
'rgba(114, 46, 209, 1)',
'rgba(82, 196, 26, 1)',
'rgba(250, 84, 28, 1)',
'rgba(22, 119, 255, 1)',
'rgba(95, 101, 108, 1)',
'rgba(47, 84, 235, 1)',
'rgba(133, 165, 255, 1)',
]
// 解析 RGBA 颜色的辅助函数
const parseRgba = (colorStr) => {
......@@ -50,19 +67,29 @@ const getMultiLineChart = (data) => {
// 动态生成 series 配置
const echartsSeries = series.map((item, index) => {
// 获取当前系列的颜色(优先使用item.color,否则用预设颜色,再否则随机)
const baseColor = item.color || colorList[index % colorList.length] || `rgba(${Math.floor(Math.random() * 256)}, ${Math.floor(Math.random() * 256)}, ${Math.floor(Math.random() * 256)}, 1)`;
const baseColor =
item.color ||
AREA_TAG_COLOR_BY_NAME[item.name] ||
fallbackColorList[index % fallbackColorList.length] ||
`rgba(${Math.floor(Math.random() * 256)}, ${Math.floor(Math.random() * 256)}, ${Math.floor(Math.random() * 256)}, 1)`;
const { r, g, b } = parseRgba(baseColor);
return ({
name: item.name,
type: 'line',
smooth: true,
lineStyle: {
color: baseColor
},
itemStyle: {
color: baseColor
},
// 新增/优化:面积填充渐变效果
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0, // 顶部
color: `rgba(${r}, ${g}, ${b}, 0.3)` // 0.3 透明度
color: `rgba(${r}, ${g}, ${b}, 0.1)` // 按需求:0.1 -> 0
},
{
offset: 1, // 底部
......@@ -131,7 +158,7 @@ const getMultiLineChart = (data) => {
}
}
],
color: colorList, // 使用预设的配色数组
// 不使用全局 color,改为每条 series 自己定色(与 AreaTag 一致)
xAxis: [
{
type: 'category',
......
const getPieChart = (data) => {
const seriesData = (Array.isArray(data) ? data : []).map((d) => {
const color = d?.color
if (!color) return d
return {
...d,
itemStyle: { ...(d.itemStyle || {}), color },
// “飞线”(labelLine)跟随领域色
labelLine: {
...(d.labelLine || {}),
lineStyle: { ...(d.labelLine?.lineStyle || {}), color }
}
}
})
let option = {
series: [
{
......@@ -61,7 +75,7 @@ const getPieChart = (data) => {
labelLinePoints: points
};
},
data: data
data: seriesData
}]
}
return option
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论