提交 d85c3e2c authored 作者: yanpeng's avatar yanpeng

出口管制相关修改

上级 3b47eb31
......@@ -393,13 +393,13 @@ export function getDomainDistribution(sanctionDate = "2025-11-11") {
* startTime: string
* }[]>}
*/
export function getEntitiesList(typeName = "实体清单", pageNum = 1, pageSize = 10, sanctionDate = "", isCn = false) {
export function getEntitiesList(sanTypeId=1, pageNum = 1, pageSize = 10, sanctionDate = "", isCn = false) {
return request200(
request({
method: "POST",
url: "/api/sanctionList/pageQuery",
data: {
typeName,
sanTypeId,
pageNum,
pageSize,
sanctionDate,
......
......@@ -392,6 +392,14 @@ export function getSingleSanctionEntitySupplyChain(params) {
});
}
// 单次制裁-深度挖掘-制裁实体信息
export function getSingleSanctionEntityInfo(id) {
return request({
method: "GET",
url: `/api/organization/sanInfo?orgId=${id}`,
});
}
// 单次制裁-深度挖掘-制裁实体股权信息
/**
* @param {Object} params
......@@ -550,10 +558,10 @@ export function getSingleSanctionEntityInternationalPaper(params) {
}
// 商业管制清单-CCL清单简介-基本信息
export function getCCLInfo() {
export function getCCLInfo(sanTypeId = 13) {
return request({
method: "GET",
url: `/api/sanctionList/baseInfo/ccl`
url: `/api/sanctionList/baseInfoById/${sanTypeId}`
});
}
......@@ -620,3 +628,11 @@ export function getCclQuery(data) {
data
});
}
// 商业管制清单-CCL清单列表-清单版本
export function getCCLVersionList() {
return request({
method: "GET",
url: `/api/ccl/version/dateList`
});
}
......@@ -120,7 +120,7 @@ const exportControlRoutes = [
name: "commercialControlList",
component: () => import("@/views/exportControl/v2.0CommercialControlList/index.vue"),
meta: {
title: "全部实体清单"
title: "商业管制清单"
}
}
]
......
<template>
<div class="view-box">
<el-empty v-if="!dataList?.length" style="padding-top: 15%;" description="暂无数据" :image-size="100" />
<el-empty v-if="!dataList?.length" style="padding-top: 15%" description="暂无数据" :image-size="100" />
<div v-if="dataList.length" class="main-content-main">
<div class="main-mask"
<div
class="main-mask"
@wheel.prevent="handleWheel"
@mousedown="handleMouseDown"
@mouseup="handleMouseUp"
@mouseleave="handleMouseUp"
@mousemove="handleMouseMove"
></div>
<div class="fishbone-container" :style="{ transform: `translate(${translateX}px, ${translateY}px) scale(${scale})`, transformOrigin: 'center center' }">
<div
class="fishbone-container"
:style="{
transform: `translate(${translateX}px, ${translateY}px) scale(${scale})`,
transformOrigin: 'center center'
}"
>
<!-- 主轴上的标签 -->
<div class="main-line" :style="{ width: dataList.length * 200 + 300 + 'px' }">
<div class="main-line-text" v-for="(item, index) in dataList" :key="'label-' + index"
<div
class="main-line-text"
v-for="(item, index) in dataList"
:key="'label-' + index"
:class="{
'blue-theme': index < 2,
'green-theme': index >= 2 && index < 4,
'purple-theme': index >= 4
}" :style="{ left: index * 200 + 220 + 'px' }">
}"
:style="{ left: index * 200 + 220 + 'px' }"
>
{{ item.text }}
</div>
</div>
<!-- 奇数索引的数据组放在上方 -->
<div v-for="(causeGroup, groupIndex) in onFilterData(1)" :key="groupIndex"
class="top-bone" :style="{ left: groupIndex * 400 + 510 + 'px', height: (causeGroup.causes?.length) * 22 + 100 + 'px' }">
<div
v-for="(causeGroup, groupIndex) in onFilterData(1)"
:key="groupIndex"
class="top-bone"
:style="{ left: groupIndex * 400 + 510 + 'px', height: causeGroup.causes?.length * 22 + 100 + 'px' }"
>
<div class="left-bone">
<div class="left-bone-item" v-for="item in getLeftItems(causeGroup.causes)" :key="item.id">
<img :src="defaultIcon2 || item.picture" alt="" class="company-icon" />
......@@ -40,8 +56,12 @@
</div>
</div>
<!-- 偶数索引的数据组放在下方 -->
<div v-for="(causeGroup, groupIndex) in onFilterData(0)" :key="groupIndex"
class="bottom-bone" :style="{ left: groupIndex * 400 + 310 + 'px', height: (causeGroup.causes?.length) * 22 + 100 + 'px' }">
<div
v-for="(causeGroup, groupIndex) in onFilterData(0)"
:key="groupIndex"
class="bottom-bone"
:style="{ left: groupIndex * 400 + 310 + 'px', height: causeGroup.causes?.length * 22 + 100 + 'px' }"
>
<div class="left-bone">
<div class="left-bone-item" v-for="item in getRightItems(causeGroup.causes)" :key="item.id">
<img :src="defaultIcon2 || item.picture" alt="" class="company-icon" />
......@@ -67,9 +87,11 @@
</div>
<div class="text">
{{
`中国企业${cnEntityOnChainData.upstreamInternalCount ||
0}家(${formatRate(cnEntityOnChainData.upstreamInternalRate)}%),受制裁${cnEntityOnChainData.upstreamEntityCount
|| 0}家(${formatRate(cnEntityOnChainData.upstreamEntityRate)}%)`
`中国企业${cnEntityOnChainData.upstreamInternalCount || 0}家(${formatRate(
cnEntityOnChainData.upstreamInternalRate
)}%),受制裁${cnEntityOnChainData.upstreamEntityCount || 0}家(${formatRate(
cnEntityOnChainData.upstreamEntityRate
)}%)`
}}
</div>
</div>
......@@ -82,9 +104,11 @@
</div>
<div class="text">
{{
`中国企业${cnEntityOnChainData.midstreamInternalCount ||
0}家(${formatRate(cnEntityOnChainData.midstreamInternalRate)}%),受制裁${cnEntityOnChainData.midstreamEntityCount
|| 0}家(${formatRate(cnEntityOnChainData.midstreamEntityRate)}%)`
`中国企业${cnEntityOnChainData.midstreamInternalCount || 0}家(${formatRate(
cnEntityOnChainData.midstreamInternalRate
)}%),受制裁${cnEntityOnChainData.midstreamEntityCount || 0}家(${formatRate(
cnEntityOnChainData.midstreamEntityRate
)}%)`
}}
</div>
</div>
......@@ -97,9 +121,11 @@
</div>
<div class="text">
{{
`中国企业${cnEntityOnChainData.downstreamInternalCount ||
0}家(${formatRate(cnEntityOnChainData.downstreamInternalRate)}%),受制裁${cnEntityOnChainData.downstreamEntityCount
|| 0}家(${formatRate(cnEntityOnChainData.downstreamEntityRate)}%)`
`中国企业${cnEntityOnChainData.downstreamInternalCount || 0}家(${formatRate(
cnEntityOnChainData.downstreamInternalRate
)}%),受制裁${cnEntityOnChainData.downstreamEntityCount || 0}家(${formatRate(
cnEntityOnChainData.downstreamEntityRate
)}%)`
}}
</div>
</div>
......@@ -113,50 +139,55 @@
import { ref, onMounted } from "vue";
import defaultIcon2 from "@/assets/icons/default-icon2.png";
import noticeIcon from "../assets/images/notice-icon.png";
import { getDeepMiningSelect, getDeepMiningIndustry, getDeepMiningIndustryFishbone, getDeepMiningIndustryEntity } from "@/api/exportControlV2.0";
import {
getDeepMiningSelect,
getDeepMiningIndustry,
getDeepMiningIndustryFishbone,
getDeepMiningIndustryEntity
} from "@/api/exportControlV2.0";
// 缩放功能处理
const scale = ref(1)
const minScale = 0.1
const maxScale = 10
const handleWheel = (e) => {
const scale = ref(1);
const minScale = 0.1;
const maxScale = 10;
const handleWheel = e => {
if (e.deltaY < 0) {
// 放大:不超过最大值
scale.value = Math.min(scale.value + 0.1, maxScale)
scale.value = Math.min(scale.value + 0.1, maxScale);
} else {
// 缩小:不低于最小值
scale.value = Math.max(scale.value - 0.1, minScale)
scale.value = Math.max(scale.value - 0.1, minScale);
}
}
};
// 移动功能处理
const translateX = ref(0) // X轴位移
const translateY = ref(0) // Y轴位移
let isDragging = false
let startX = 0
let startY = 0
const handleMouseMove = (e) => {
if (!isDragging) return
translateX.value = e.clientX - startX
translateY.value = e.clientY - startY
}
const handleMouseDown = (e) => {
const translateX = ref(0); // X轴位移
const translateY = ref(0); // Y轴位移
let isDragging = false;
let startX = 0;
let startY = 0;
const handleMouseMove = e => {
if (!isDragging) return;
translateX.value = e.clientX - startX;
translateY.value = e.clientY - startY;
};
const handleMouseDown = e => {
// 排除右键/中键,只响应左键(e.button=0为左键)
if (e.button !== 0) return
isDragging = true
startX = e.clientX - translateX.value
startY = e.clientY - translateY.value
}
if (e.button !== 0) return;
isDragging = true;
startX = e.clientX - translateX.value;
startY = e.clientY - translateY.value;
};
const handleMouseUp = () => {
isDragging = false
}
isDragging = false;
};
// 实体清单-深度挖掘-产业链中国企业实体信息查询
const cnEntityOnChainData = ref({});
const getCnEntityOnChainData = async () => {
const currentSanction = sanctionList.value.find(item => item.id === currentSanctionId.value);
const date = currentSanction ? currentSanction.date : '';
const date = currentSanction ? currentSanction.date : "";
// 确保 date 格式正确
const formattedDate = date && date.includes('年') ? date.replace('年', '-').replace('月', '-').replace('日', '') : date;
const formattedDate = date && date.includes("年") ? date.replace("年", "-").replace("月", "-").replace("日", "") : date;
const params = {
date: formattedDate
......@@ -166,7 +197,7 @@ const getCnEntityOnChainData = async () => {
}
try {
const res = await getDeepMiningIndustryEntity(params);
console.log("企业信息", res)
console.log("企业信息", res);
if (res.code === 200 && res.data) {
cnEntityOnChainData.value = res.data;
} else {
......@@ -176,12 +207,12 @@ const getCnEntityOnChainData = async () => {
console.error("获取产业链中国企业实体信息失败:", error);
cnEntityOnChainData.value = {};
}
}
};
// 产业链鱼骨数据
const dataList = ref([]);
// 奇数索引的数据组放在上方, 偶数索引的数据组放在下方
const onFilterData = (num) => {
const onFilterData = num => {
return dataList.value.filter((_, index) => index % 2 === num);
};
// 获取左侧显示的项目(前半部分)
......@@ -196,9 +227,9 @@ const getRightItems = items => {
};
const getFishboneData = async () => {
const currentSanction = sanctionList.value.find(item => item.id === currentSanctionId.value);
const date = currentSanction ? currentSanction.date : '';
const date = currentSanction ? currentSanction.date : "";
// 确保 date 格式正确
const formattedDate = date && date.includes('年') ? date.replace('年', '-').replace('月', '-').replace('日', '') : date;
const formattedDate = date && date.includes("年") ? date.replace("年", "-").replace("月", "-").replace("日", "") : date;
const params = {
date: formattedDate
......@@ -218,7 +249,7 @@ const getFishboneData = async () => {
console.error("获取产业链鱼骨图数据失败:", error);
dataList.value = [];
}
}
};
// 实体清单-深度挖掘-产业链列表信息
const selectedIndustryId = ref(null);
......@@ -226,12 +257,10 @@ const getIndustryList = async () => {
try {
const res = await getDeepMiningIndustry();
if (res.code === 200 && res.data && res.data.length > 0) {
selectedIndustryId.value = res.data[0].id;
getFishboneData();
getCnEntityOnChainData();
} else {
selectedIndustryId.value = null;
}
} catch (error) {
......@@ -239,7 +268,7 @@ const getIndustryList = async () => {
selectedIndustryId.value = null;
}
}
};
// 获取选择制裁
const loading = ref(false);
......@@ -249,8 +278,8 @@ const pageSize = ref(10000);
const getDeepMiningSelectData = async () => {
loading.value = true;
const params = {
startDate: dateRange.value && dateRange.value[0] ? dateRange.value[0] : '',
endDate: dateRange.value && dateRange.value[1] ? dateRange.value[1] : '',
startDate: dateRange.value && dateRange.value[0] ? dateRange.value[0] : "",
endDate: dateRange.value && dateRange.value[1] ? dateRange.value[1] : "",
typeName: "实体清单",
isCn: false,
pageNum: currentPage.value,
......@@ -264,7 +293,7 @@ const getDeepMiningSelectData = async () => {
date: item.postDate,
title: item.name,
count: item.cnEntityCount,
unit: '家中国实体', // 接口未返回单位,暂时固定
unit: "家中国实体", // 接口未返回单位,暂时固定
summary: item.summary, // 保留额外信息备用
techDomainList: item.techDomainList // 保留额外信息备用
}));
......@@ -283,7 +312,7 @@ const getDeepMiningSelectData = async () => {
} finally {
loading.value = false;
}
}
};
const dateRange = ref(["2025-01-01", "2025-12-31"]);
const sanctionList = ref([
......@@ -299,8 +328,8 @@ const sanctionList = ref([
const currentSanctionId = ref(5);
// 格式化比率
const formatRate = (rate, ratio=false) => {
if (!rate) return '0.00';
const formatRate = (rate, ratio = false) => {
if (!rate) return "0.00";
return (rate * 100).toFixed(2);
};
......@@ -351,7 +380,7 @@ onMounted(() => {
position: absolute;
// top: -14px;
font-size: 16px;
color: #055FC2;
color: #055fc2;
font-weight: bold;
background-color: #f7f8f9;
padding: 0 10px;
......
......@@ -1453,7 +1453,7 @@ watch(
// 获取实体清单数据
const fetchEntitiesList = async (page = 1, size = 10) => {
try {
const res = await getEntitiesList("实体清单", page, size);
const res = await getEntitiesList(activeResourceTabItem.value.id, page, size);
if (res) {
entitiesList.value = res.content.map(item => ({
...item,
......
......@@ -407,7 +407,7 @@ const CCLInfo = ref({
});
const getCCLInfoFn = async () => {
try {
const res = await getCCLInfo();
const res = await getCCLInfo(route.query.sanTypeId || 13);
if (res && res.code === 200) {
CCLInfo.value = res.data;
console.log("getCCLInfoFn", CCLInfo.value);
......
......@@ -18,16 +18,26 @@
<div class="text">科技领域</div>
</div>
<div class="checkbox-group">
<el-checkbox v-for="(item, index) in techFields" :key="index" v-model="item.checked" :label="item.name"
@change="handleFilterChange(item, techFields, 'tech')" />
<el-checkbox
v-for="(item, index) in techFields"
:key="index"
v-model="item.checked"
:label="item.name"
@change="handleFilterChange(item, techFields, 'tech')"
/>
</div>
<div class="title">
<div class="box"></div>
<div class="text">管控原因</div>
</div>
<div class="checkbox-group">
<el-checkbox v-for="(item, index) in controlReason" :key="index" v-model="item.checked" :label="item.name"
@change="handleFilterChange(item, controlReason, 'reason')" />
<el-checkbox
v-for="(item, index) in controlReason"
:key="index"
v-model="item.checked"
:label="item.name"
@change="handleFilterChange(item, controlReason, 'reason')"
/>
</div>
</div>
<div class="right">
......@@ -41,7 +51,7 @@
</div>
<div style="width: 100%" v-if="item.isExpand">
<div style="width: 100%" v-for="element,index in item.cclChildren" :key="index">
<div style="width: 100%" v-for="(element, index) in item.cclChildren" :key="index">
<div class="list-desc">{{ element.cclCode }}. {{ element.cclTitleZh }}</div>
<div class="list-content" v-for="(ele, j) in element.cclChildren" :key="j">
......@@ -74,71 +84,80 @@ import { ref, computed, onMounted, watch } from "vue";
import { useRouter } from "vue-router";
import { Search, Select } from "@element-plus/icons-vue";
import defaultIcon from "../../../../../assets/icons/default-avatar.png";
import { getECCNCategory, getAreaType, getControlReason, getCclQuery, getExportControlList, get50PercentEntityCount } from "@/api/exportControlV2.0.js"
import {
getECCNCategory,
getAreaType,
getControlReason,
getCclQuery,
getCCLVersionList,
getExportControlList,
get50PercentEntityCount
} from "@/api/exportControlV2.0.js";
const router = useRouter();
const currentCCLType = ref('')
const CCLTypeList = ref([])
const currentCCLType = ref("");
const CCLTypeList = ref([]);
const getTypeList = async () => {
try {
const res = await getECCNCategory()
const res = await getECCNCategory();
if (res && res.code === 200) {
console.log('-----getTypeList', res.data)
CCLTypeList.value = res.data
currentCCLType.value = CCLTypeList.value[0].id
console.log("-----getTypeList", res.data);
CCLTypeList.value = res.data;
currentCCLType.value = CCLTypeList.value[0].id;
}
} catch (error) {
console.error("获取类别字典失败:", error);
}
}
};
const techFields = ref([])
const techFields = ref([]);
const getTechFields = async () => {
try {
const res = await getAreaType()
const res = await getAreaType();
if (res && res.code === 200) {
console.log('-----getTechFields', res.data)
techFields.value = res.data
console.log("-----getTechFields", res.data);
techFields.value = res.data;
// 默认选中第一个
techFields.value[0].checked = true
techFields.value[0].checked = true;
}
} catch (error) {
console.error("获取科技领域字典失败:", error);
}
}
};
const controlReason = ref([])
const controlReason = ref([]);
const getControlReasonList = async () => {
try {
const res = await getControlReason()
const res = await getControlReason();
if (res && res.code === 200) {
console.log('-----getControlReasonList', res.data)
controlReason.value = res.data
console.log("-----getControlReasonList", res.data);
controlReason.value = res.data;
// 默认选中第一个
controlReason.value[0].checked = true
controlReason.value[0].checked = true;
}
} catch (error) {
console.error("获取管控原因字典失败:", error);
}
}
};
const searchKeyword = ref('');
const searchKeyword = ref("");
const onlyChina = ref(false);
const viewNew = ref(true)
const viewNew = ref(true);
// 获取ccl清单列表
const getCclList = async () => {
let techDomains = techFields.value.filter(item => item.checked).map(item => +item.id)
let controls = controlReason.value.filter(item => item.checked).map(item => +item.id)
let techDomains = techFields.value.filter(item => item.checked).map(item => +item.id);
let controls = controlReason.value.filter(item => item.checked).map(item => +item.id);
const params = {
categoryCode: currentCCLType.value,
techDomainIds: techDomains,
keyword: searchKeyword.value || '',
keyword: searchKeyword.value || "",
controlIds: controls,
isLatest: viewNew.value
}
console.log(JSON.stringify(params))
isLatest: viewNew.value,
recordId: ""
};
console.log(JSON.stringify(params));
try {
// const res = await getCclQuery(null);
const res = await getCclQuery(params);
......@@ -207,38 +226,52 @@ const getCclList = async () => {
// })
// })
if (res && res.code === 200) {
console.log('----getCclList', res.data)
cclList.value = res.data
console.log("----getCclList", res.data);
cclList.value = res.data;
// 给数据添加isExpand字段
cclList.value.forEach((item) => {
item.isExpand = false
item.cclChildren.forEach((ele) => {
ele.cclChildren.forEach((i) => {
i.isExpand = false
})
})
})
cclList.value.forEach(item => {
item.isExpand = false;
item.cclChildren.forEach(ele => {
ele.cclChildren.forEach(i => {
i.isExpand = false;
});
});
});
}
} catch (error) {
console.error("获取ccl清单列表失败:", error);
}
};
// 商业管制清单-CCL清单列表-清单版本
const cclVersionList = ref([]);
// 获取清单版本列表
const getCCLVersionListApi = async () => {
try {
const res = await getCCLVersionList();
if (res && res.code === 200) {
console.log("----getCCLVersionList", res.data);
cclVersionList.value = res.data;
}
}
} catch (error) {
console.error("获取清单版本列表失败:", error);
}
};
// 筛选逻辑处理
const handleFilterChange = (item, list, type) => {
console.log(item, list, type)
getCclList()
console.log(item, list, type);
getCclList();
};
watch(viewNew, (newValue) => {
watch(viewNew, newValue => {
getCclList();
});
watch(currentCCLType, (newValue) => {
console.log(newValue)
getCclList()
})
watch(currentCCLType, newValue => {
console.log(newValue);
getCclList();
});
// const searchDebounceTimer = ref(null);
// watch(searchKeyword, () => {
......@@ -250,52 +283,52 @@ watch(currentCCLType, (newValue) => {
// getExportControlListApi();
// }, 300);
// });
watch(searchKeyword, (newValue) => {
console.log('-----searchKey', newValue)
getCclList()
watch(searchKeyword, newValue => {
console.log("-----searchKey", newValue);
getCclList();
});
// 模拟清单列表
const cclList = ref([
{
name: '类别 0-核材料、设施和设备、枪支、弹药[以及其他物品]',
name: "类别 0-核材料、设施和设备、枪支、弹药[以及其他物品]",
desc: 'A."最终产品"、"设备"、"附件"、"附加装置"、"零件"、"组件"和"系统"',
isExpand: true,
list: [
{
code: '0A002',
code: "0A002",
name: '发电或推进设备,"专门设计"用于与太空、海洋或移动"核反应堆"一起使用。(这些项目"受 ITAR 管辖。"参见 22 CFR 第 120 至 130 部分。)',
isExpand: false,
isDot: false
},
{
code: '0A501',
name: '枪支(不包括 0A502 霰弹枪、0A506 半自动步枪、0A507 半自动手枪和 0A508 半自动霰弹枪)及相关商品(不包括在 Eccn 0A509 中列举或以其他方式描述的与半自动相关的商品,用于 Eccn 0A506、0A507 或 0A508)如下(参见受控物品清单)',
code: "0A501",
name: "枪支(不包括 0A502 霰弹枪、0A506 半自动步枪、0A507 半自动手枪和 0A508 半自动霰弹枪)及相关商品(不包括在 Eccn 0A509 中列举或以其他方式描述的与半自动相关的商品,用于 Eccn 0A506、0A507 或 0A508)如下(参见受控物品清单)",
isExpand: true,
isDot: true
},
{
code: '0A501',
name: '枪支(不包括 0A502 霰弹枪、0A506 半自动步枪、0A507 半自动手枪和 0A508 半自动霰弹枪)及相关商品(不包括在 Eccn 0A509 中列举或以其他方式描述的与半自动相关的商品,用于 Eccn 0A506、0A507 或 0A508)如下(参见受控物品清单)',
code: "0A501",
name: "枪支(不包括 0A502 霰弹枪、0A506 半自动步枪、0A507 半自动手枪和 0A508 半自动霰弹枪)及相关商品(不包括在 Eccn 0A509 中列举或以其他方式描述的与半自动相关的商品,用于 Eccn 0A506、0A507 或 0A508)如下(参见受控物品清单)",
isExpand: false,
isDot: true
}
]
}
])
]);
onMounted(async () => {
// 获取类别字段
await getTypeList()
await getTypeList();
// 获取科技领域字典
await getTechFields()
await getTechFields();
// 获取管控原因字典
await getControlReasonList()
await getControlReasonList();
// 获取ccl清单列表
getCclList();
// 获取清单版本列表
getCCLVersionListApi();
});
</script>
<style scoped lang="scss">
......
......@@ -17,8 +17,8 @@
class="nav-item"
v-for="(item, index) in headerNavList"
:key="index"
:class="{ active: activeIndex === index }"
@click="activeIndex = index"
:class="{ active: activeIndex === index, disabled: item.disable }"
@click="!item.disable && (activeIndex = index)"
>
<img :src="activeIndex === index ? item.imgActive : item.img" alt />
<span>{{ item.title }}</span>
......@@ -81,17 +81,20 @@ const headerNavList = ref([
{
img: icon5,
imgActive: icon5Active,
title: "数据统计"
title: "数据统计",
disable: true
},
{
img: icon2,
imgActive: icon2Active,
title: "深度挖掘"
title: "深度挖掘",
disable: true
},
{
img: icon3,
imgActive: icon3Active,
title: "影响分析"
title: "影响分析",
disable: true
}
]);
</script>
......@@ -206,6 +209,11 @@ const headerNavList = ref([
font-weight: 700;
}
&.disabled {
cursor: not-allowed;
color: rgb(95, 101, 108);
}
.active-line {
position: absolute;
bottom: 0;
......
<template>
<div class="relation-graph-wrapper">
<div class="graph-controls">
<div
v-for="item in controlBtns"
:key="item.type"
:class="['control-btn', { 'control-btn-active': currentLayoutType === item.type }]"
@click="handleClickControlBtn(item.type)"
>
<img :src="item.icon" alt="" />
</div>
</div>
<div ref="containerRef" class="graph-container"></div>
<div v-if="selectedNode" class="node-popup">
<div class="popup-header">
<img :src="selectedNode.image || defaultIcon" alt="" class="popup-icon" />
<div class="popup-title">{{ selectedNode.name }}</div>
<el-icon class="close-icon" @click="selectedNode = null">
<Close />
</el-icon>
</div>
<div class="popup-body">
<div v-if="selectedNode.isSanctioned" class="tag-row">
<span class="red-dot"></span>
<span class="red-text">被制裁实体</span>
</div>
<div v-if="selectedNode.description && selectedNode.description.length > 0" class="desc-row">
<p class="desc" v-for="item in selectedNode.description" :key="item">{{ item }}</p>
</div>
<p v-else class="desc">暂无描述</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from "vue";
import G6 from "@antv/g6";
import { Close } from "@element-plus/icons-vue";
import echartsIcon01 from "../assets/echartsicon01.png";
import echartsIcon02 from "../assets/echartsicon02.png";
import echartsIcon03 from "../assets/echartsicon03.png";
import defaultIcon from "../assets/echartsicon03.png";
import { getSingleSanctionEntityInfo } from "@/api/exportControlV2.0";
const props = defineProps({
graphData: {
type: Object,
default: () => ({ nodes: [], links: [] })
},
treeData: {
type: Object,
default: () => null
},
controlActive: {
type: Number,
default: 1
}
});
const emit = defineEmits(["nodeClick", "layoutChange"]);
const containerRef = ref(null);
const graphInstance = ref(null);
const currentLayoutType = ref(1);
const selectedNode = ref(null);
const controlBtns = [
{ type: 1, icon: echartsIcon01, name: "力导向布局" },
{ type: 2, icon: echartsIcon02, name: "树布局" },
{ type: 3, icon: echartsIcon03, name: "环状布局" }
];
const initGraph = (layoutType = 1) => {
if (!containerRef.value) return;
destroyGraph();
nextTick(() => {
const container = containerRef.value;
const width = container.offsetWidth || 800;
const height = container.offsetHeight || 600;
if (layoutType === 2) {
initTreeGraph(width, height);
} else if (layoutType === 3) {
initCircularGraph(width, height);
} else {
initNormalGraph(layoutType, width, height);
}
});
};
const initNormalGraph = (layoutType, width, height) => {
const data = processGraphData(props.graphData);
if (!data.nodes || data.nodes.length === 0) return;
const layout = {
type: "force",
center: [width / 2, height / 2],
preventOverlap: true,
nodeSpacing: 80,
linkDistance: 250,
nodeStrength: -800,
edgeStrength: 0.1,
collideStrength: 0.8,
alphaDecay: 0.01,
alphaMin: 0.001
};
graphInstance.value = new G6.Graph({
container: containerRef.value,
width,
height,
fitView: true,
fitViewPadding: 100,
fitCenter: true,
animate: true,
animateCfg: {
duration: 300,
easing: "easeLinear"
},
minZoom: 0.1,
maxZoom: 10,
modes: {
default: [
"drag-canvas",
"zoom-canvas",
"drag-node",
{
type: "activate-relations",
trigger: "mouseenter",
resetSelected: true
}
]
},
layout,
defaultNode: {
type: "image",
size: 40,
clipCfg: {
show: true,
type: "circle",
r: 20
},
labelCfg: {
position: "bottom",
offset: 10,
style: {
fill: "#333",
fontSize: 11,
fontFamily: "Microsoft YaHei",
textAlign: "center",
background: {
fill: "rgba(255, 255, 255, 0.95)",
padding: [4, 6, 4, 6],
radius: 4
}
}
}
},
defaultEdge: {
type: "quadratic",
style: {
stroke: "#5B8FF9",
lineWidth: 3,
opacity: 0.9,
// 添加边的边框效果
strokeOpacity: 1,
endArrow: {
path: "M 0,0 L 12,6 L 12,-6 Z",
fill: "#5B8FF9"
}
},
labelCfg: {
autoRotate: true,
style: {
fill: "#333",
fontSize: 10,
fontFamily: "Microsoft YaHei",
background: {
fill: "#fff",
padding: [2, 4, 2, 4],
radius: 2
}
}
}
},
nodeStateStyles: {
active: {
shadowColor: "#1459BB",
shadowBlur: 15,
stroke: "#1459BB",
lineWidth: 3
},
inactive: {
opacity: 0.3
}
},
edgeStateStyles: {
active: {
stroke: "#1459BB",
lineWidth: 4
},
inactive: {
opacity: 0.15
}
}
});
graphInstance.value.data(data);
graphInstance.value.render();
bindGraphEvents();
};
const initCircularGraph = (width, height) => {
const data = processGraphData(props.graphData);
if (!data.nodes || data.nodes.length === 0) return;
const centerX = width / 2;
const centerY = height / 2;
const radius = Math.min(width, height) / 2 - 120;
const otherNodes = data.nodes.filter(n => !n.isCenter);
const nodeCount = otherNodes.length;
otherNodes.forEach((node, index) => {
const angle = (2 * Math.PI * index) / nodeCount - Math.PI / 2;
node.x = centerX + radius * Math.cos(angle);
node.y = centerY + radius * Math.sin(angle);
});
const centerNode = data.nodes.find(n => n.isCenter);
if (centerNode) {
centerNode.x = centerX;
centerNode.y = centerY;
centerNode.fx = centerX;
centerNode.fy = centerY;
}
graphInstance.value = new G6.Graph({
container: containerRef.value,
width,
height,
fitView: false,
fitCenter: false,
animate: true,
animateCfg: {
duration: 300,
easing: "easeLinear"
},
minZoom: 0.1,
maxZoom: 10,
modes: {
default: [
"drag-canvas",
"zoom-canvas",
"drag-node",
{
type: "activate-relations",
trigger: "mouseenter",
resetSelected: true
}
]
},
defaultNode: {
type: "image",
size: 40,
clipCfg: {
show: true,
type: "circle",
r: 20
},
labelCfg: {
position: "bottom",
offset: 10,
style: {
fill: "#333",
fontSize: 11,
fontFamily: "Microsoft YaHei",
textAlign: "center",
background: {
fill: "rgba(255, 255, 255, 0.95)",
padding: [4, 6, 4, 6],
radius: 4
}
}
}
},
defaultEdge: {
type: "quadratic",
style: {
stroke: "#5B8FF9",
lineWidth: 3,
opacity: 0.9,
endArrow: {
path: "M 0,0 L 12,6 L 12,-6 Z",
fill: "#5B8FF9"
}
},
labelCfg: {
autoRotate: true,
style: {
fill: "#333",
fontSize: 10,
fontFamily: "Microsoft YaHei",
background: {
fill: "#fff",
padding: [2, 4, 2, 4],
radius: 2
}
}
}
},
nodeStateStyles: {
active: {
shadowColor: "#1459BB",
shadowBlur: 15,
stroke: "#1459BB",
lineWidth: 3
},
inactive: {
opacity: 0.3
}
},
edgeStateStyles: {
active: {
stroke: "#1459BB",
lineWidth: 4
},
inactive: {
opacity: 0.15
}
}
});
graphInstance.value.data(data);
graphInstance.value.render();
bindGraphEvents();
};
const initTreeGraph = (width, height) => {
const treeDataSource = convertGraphToTree(props.graphData);
if (!treeDataSource) return;
graphInstance.value = new G6.TreeGraph({
container: containerRef.value,
width,
height,
fitView: true,
fitViewPadding: 80,
animate: true,
animateCfg: {
duration: 300,
easing: "easeLinear"
},
minZoom: 0.1,
maxZoom: 10,
modes: {
default: [
"drag-canvas",
"zoom-canvas",
"drag-node",
{
type: "collapse-expand",
onChange: function onChange(item, collapsed) {
const data = item.getModel();
data.collapsed = collapsed;
return true;
}
}
]
},
layout: {
type: "compactBox",
direction: "LR",
getId: function getId(d) {
return d.id;
},
getHeight: function getHeight() {
return 16;
},
getWidth: function getWidth() {
return 16;
},
getVGap: function getVGap() {
return 30;
},
getHGap: function getHGap() {
return 120;
}
},
defaultNode: {
type: "image",
size: 40,
clipCfg: {
show: true,
type: "circle",
r: 20
},
labelCfg: {
position: "right",
offset: 10,
style: {
fill: "#333",
fontSize: 11,
fontFamily: "Microsoft YaHei",
background: {
fill: "rgba(255, 255, 255, 0.95)",
padding: [4, 6, 4, 6],
radius: 4
}
}
}
},
defaultEdge: {
type: "cubic-horizontal",
style: {
stroke: "#5B8FF9",
lineWidth: 3
}
},
nodeStateStyles: {
active: {
shadowColor: "#1459BB",
shadowBlur: 15,
stroke: "#1459BB",
lineWidth: 3
}
}
});
graphInstance.value.data(treeDataSource);
graphInstance.value.render();
graphInstance.value.fitView();
bindGraphEvents();
};
const convertGraphToTree = graphData => {
if (!graphData || !graphData.nodes || graphData.nodes.length === 0) {
return null;
}
const nodes = graphData.nodes;
const links = graphData.links || graphData.edges || [];
const centerNode = nodes[0];
const centerId = String(centerNode.id || "0");
const childIdSet = new Set();
const childrenNodes = [];
links.forEach(link => {
const source = String(link.source);
const target = String(link.target);
if (source === centerId && !childIdSet.has(target)) {
const node = nodes.find(n => String(n.id) === target);
if (node) {
childIdSet.add(target);
childrenNodes.push({
id: target,
label: node.name || "",
img: node.image || defaultIcon,
size: node.symbolSize || 40,
name: node.name,
image: node.image,
isSanctioned: node.isSanctioned
});
}
} else if (target === centerId && !childIdSet.has(source)) {
const node = nodes.find(n => String(n.id) === source);
if (node) {
childIdSet.add(source);
childrenNodes.push({
id: source,
label: node.name || "",
img: node.image || defaultIcon,
size: node.symbolSize || 40,
name: node.name,
image: node.image,
isSanctioned: node.isSanctioned
});
}
}
});
if (childrenNodes.length === 0) {
nodes.slice(1).forEach(node => {
const nodeId = String(node.id);
if (!childIdSet.has(nodeId)) {
childIdSet.add(nodeId);
childrenNodes.push({
id: nodeId,
label: node.name || "",
img: node.image || defaultIcon,
size: node.symbolSize || 40,
name: node.name,
image: node.image,
isSanctioned: node.isSanctioned
});
}
});
}
return {
id: centerId,
label: centerNode.name || "",
img: centerNode.image || defaultIcon,
size: centerNode.symbolSize || 60,
name: centerNode.name,
image: centerNode.image,
isSanctioned: centerNode.isSanctioned,
children: childrenNodes
};
};
const processGraphData = rawData => {
if (!rawData || !rawData.nodes || rawData.nodes.length === 0) {
return { nodes: [], edges: [] };
}
const nodeMap = new Map();
const nodes = [];
rawData.nodes.forEach((node, index) => {
const nodeId = String(node.id || index);
if (nodeMap.has(nodeId)) {
return;
}
nodeMap.set(nodeId, true);
const isCenter = index === 0;
const size = node.symbolSize || (isCenter ? 60 : 40);
nodes.push({
id: nodeId,
label: node.name || "",
img: node.image || defaultIcon,
size,
isCenter,
clipCfg: {
show: true,
type: "circle",
r: size / 2
},
style: {
cursor: "pointer"
},
labelCfg: {
position: "bottom",
offset: 12,
style: {
fill: isCenter ? "#1459BB" : "#333",
fontSize: isCenter ? 13 : 11,
fontWeight: isCenter ? "bold" : "normal",
fontFamily: "Microsoft YaHei",
textAlign: "center"
}
},
...node,
id: nodeId
});
});
const edgeMap = new Map();
const edges = [];
const rawEdges = rawData.links || rawData.edges || [];
rawEdges.forEach((edge, index) => {
const source = String(edge.source);
const target = String(edge.target);
const edgeKey = `${source}-${target}`;
if (edgeMap.has(edgeKey)) {
return;
}
if (!nodeMap.has(source) || !nodeMap.has(target)) {
return;
}
edgeMap.set(edgeKey, true);
edges.push({
id: `edge-${index}`,
source,
target,
label: edge.name || ""
});
});
return { nodes, edges };
};
const bindGraphEvents = () => {
if (!graphInstance.value) return;
graphInstance.value.on("node:click", evt => {
const node = evt.item;
const model = node.getModel();
console.log("点击节点model", model);
const id = model.id.split("-")[1];
let desList = [];
getSingleSanctionEntityInfo(id).then(res => {
console.log("制裁实体信息", res);
if (res.data.sanInfoList && res.data.sanInfoList.length > 0) {
desList = res.data.sanInfoList.map(item => item.sanReason);
}
});
selectedNode.value = { ...model, description: desList };
emit("nodeClick", model);
});
graphInstance.value.on("canvas:click", () => {
selectedNode.value = null;
});
};
const handleClickControlBtn = btn => {
currentLayoutType.value = btn;
emit("layoutChange", btn);
initGraph(btn);
};
const destroyGraph = () => {
if (graphInstance.value) {
graphInstance.value.destroy();
graphInstance.value = null;
}
};
const handleResize = () => {
if (graphInstance.value && containerRef.value) {
const width = containerRef.value.offsetWidth;
const height = containerRef.value.offsetHeight;
graphInstance.value.changeSize(width, height);
graphInstance.value.fitView();
}
};
watch(
() => props.graphData,
() => {
initGraph(currentLayoutType.value);
},
{ deep: true }
);
watch(
() => props.treeData,
() => {
if (currentLayoutType.value === 2) {
initGraph(2);
}
},
{ deep: true }
);
watch(
() => props.controlActive,
newVal => {
if (newVal !== currentLayoutType.value) {
handleClickControlBtn(newVal);
}
}
);
onMounted(() => {
initGraph(1);
window.addEventListener("resize", handleResize);
});
onUnmounted(() => {
window.removeEventListener("resize", handleResize);
destroyGraph();
});
defineExpose({
refresh: () => initGraph(currentLayoutType.value),
changeLayout: type => handleClickControlBtn(type),
getGraph: () => graphInstance.value
});
</script>
<style lang="scss" scoped>
.relation-graph-wrapper {
position: relative;
width: 100%;
height: 100%;
}
.graph-container {
width: 100%;
height: 100%;
}
.graph-controls {
position: absolute;
top: 16px;
right: 16px;
display: flex;
gap: 8px;
z-index: 10;
.control-btn {
width: 32px;
height: 32px;
border-radius: 4px;
border: 1px solid rgba(234, 236, 238, 1);
background: rgba(255, 255, 255, 1);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
img {
width: 16px;
height: 16px;
}
&:hover {
border-color: rgba(5, 95, 194, 0.5);
}
}
.control-btn-active {
border-color: rgba(5, 95, 194, 1);
background: rgba(231, 243, 255, 1);
}
}
.node-popup {
position: absolute;
bottom: 16px;
left: 16px;
width: 320px;
background: rgba(255, 255, 255, 1);
border-radius: 8px;
box-shadow: 0px 4px 16px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(234, 236, 238, 1);
z-index: 20;
.popup-header {
display: flex;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid rgba(234, 236, 238, 1);
.popup-icon {
width: 32px;
height: 32px;
margin-right: 8px;
border-radius: 50%;
object-fit: cover;
}
.popup-title {
flex: 1;
font-size: 16px;
font-weight: 700;
font-family: "Microsoft YaHei";
color: rgba(59, 65, 75, 1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.close-icon {
cursor: pointer;
color: rgba(132, 136, 142, 1);
font-size: 16px;
&:hover {
color: rgba(5, 95, 194, 1);
}
}
}
.popup-body {
padding: 12px 16px;
.tag-row {
display: flex;
align-items: center;
margin-bottom: 8px;
.red-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(245, 63, 63, 1);
margin-right: 8px;
}
.red-text {
font-size: 14px;
font-family: "Microsoft YaHei";
color: rgba(245, 63, 63, 1);
}
}
.desc {
font-size: 14px;
font-family: "Microsoft YaHei";
line-height: 22px;
color: rgba(95, 101, 108, 1);
margin: 0;
}
}
}
</style>
<template>
<div class="relation-graph-wrapper">
<div class="graph-controls">
<div v-for="item in controlBtns" :key="item.type"
<div
v-for="item in controlBtns"
:key="item.type"
:class="['control-btn', { 'control-btn-active': currentLayoutType === item.type }]"
@click="handleClickControlBtn(item.type)">
@click="handleClickControlBtn(item.type)"
>
<img :src="item.icon" alt="" />
</div>
</div>
......@@ -22,20 +25,25 @@
<span class="red-dot"></span>
<span class="red-text">被制裁实体</span>
</div>
<p class="desc">{{ selectedNode.description || '暂无描述' }}</p>
<div v-if="selectedNode.description && selectedNode.description.length > 0" class="desc-row">
<p class="desc" v-for="item in selectedNode.description" :key="item">{{ item }}</p>
</div>
<p v-else class="desc">暂无描述</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import G6 from '@antv/g6'
import { Close } from '@element-plus/icons-vue'
import echartsIcon01 from '../assets/echartsicon01.png'
import echartsIcon02 from '../assets/echartsicon02.png'
import echartsIcon03 from '../assets/echartsicon03.png'
import defaultIcon from '../assets/echartsicon03.png'
import { ref, onMounted, onUnmounted, watch, nextTick } from "vue";
import G6 from "@antv/g6";
import { Close } from "@element-plus/icons-vue";
import echartsIcon01 from "../assets/echartsicon01.png";
import echartsIcon02 from "../assets/echartsicon02.png";
import echartsIcon03 from "../assets/echartsicon03.png";
import defaultIcon from "../assets/echartsicon03.png";
import { getSingleSanctionEntityInfo } from "@/api/exportControlV2.0";
const props = defineProps({
graphData: {
......@@ -50,48 +58,48 @@ const props = defineProps({
type: Number,
default: 1
}
})
});
const emit = defineEmits(['nodeClick', 'layoutChange'])
const emit = defineEmits(["nodeClick", "layoutChange"]);
const containerRef = ref(null)
const graphInstance = ref(null)
const currentLayoutType = ref(1)
const selectedNode = ref(null)
const containerRef = ref(null);
const graphInstance = ref(null);
const currentLayoutType = ref(1);
const selectedNode = ref(null);
const controlBtns = [
{ type: 1, icon: echartsIcon01, name: '力导向布局' },
{ type: 2, icon: echartsIcon02, name: '树布局' },
{ type: 3, icon: echartsIcon03, name: '环状布局' }
]
{ type: 1, icon: echartsIcon01, name: "力导向布局" },
{ type: 2, icon: echartsIcon02, name: "树布局" },
{ type: 3, icon: echartsIcon03, name: "环状布局" }
];
const initGraph = (layoutType = 1) => {
if (!containerRef.value) return
if (!containerRef.value) return;
destroyGraph()
destroyGraph();
nextTick(() => {
const container = containerRef.value
const width = container.offsetWidth || 800
const height = container.offsetHeight || 600
const container = containerRef.value;
const width = container.offsetWidth || 800;
const height = container.offsetHeight || 600;
if (layoutType === 2) {
initTreeGraph(width, height)
initTreeGraph(width, height);
} else if (layoutType === 3) {
initCircularGraph(width, height)
initCircularGraph(width, height);
} else {
initNormalGraph(layoutType, width, height)
initNormalGraph(layoutType, width, height);
}
})
}
});
};
const initNormalGraph = (layoutType, width, height) => {
const data = processGraphData(props.graphData)
const data = processGraphData(props.graphData);
if (!data.nodes || data.nodes.length === 0) return
if (!data.nodes || data.nodes.length === 0) return;
const layout = {
type: 'force',
type: "force",
center: [width / 2, height / 2],
preventOverlap: true,
nodeSpacing: 80,
......@@ -101,7 +109,7 @@ const initNormalGraph = (layoutType, width, height) => {
collideStrength: 0.8,
alphaDecay: 0.01,
alphaMin: 0.001
}
};
graphInstance.value = new G6.Graph({
container: containerRef.value,
......@@ -113,66 +121,69 @@ const initNormalGraph = (layoutType, width, height) => {
animate: true,
animateCfg: {
duration: 300,
easing: 'easeLinear'
easing: "easeLinear"
},
minZoom: 0.1,
maxZoom: 10,
modes: {
default: [
'drag-canvas',
'zoom-canvas',
'drag-node',
"drag-canvas",
"zoom-canvas",
"drag-node",
{
type: 'activate-relations',
trigger: 'mouseenter',
type: "activate-relations",
trigger: "mouseenter",
resetSelected: true
}
]
},
layout,
defaultNode: {
type: 'image',
type: "image",
size: 40,
clipCfg: {
show: true,
type: 'circle',
type: "circle",
r: 20
},
labelCfg: {
position: 'bottom',
position: "bottom",
offset: 10,
style: {
fill: '#333',
fill: "#333",
fontSize: 11,
fontFamily: 'Microsoft YaHei',
textAlign: 'center',
fontFamily: "Microsoft YaHei",
textAlign: "center",
background: {
fill: 'rgba(255, 255, 255, 0.95)',
fill: "rgba(255, 255, 255, 0.95)",
padding: [4, 6, 4, 6],
radius: 4
}
}
}
// 注意:节点边框样式在 processGraphData 中单独设置,不在这里设置
},
defaultEdge: {
type: 'quadratic',
type: "quadratic",
style: {
stroke: '#5B8FF9',
stroke: "#5B8FF9",
lineWidth: 3,
opacity: 0.9,
shadowColor: "rgba(231, 243, 255, 1)",
shadowBlur: 4,
endArrow: {
path: 'M 0,0 L 12,6 L 12,-6 Z',
fill: '#5B8FF9'
path: "M 0,0 L 12,6 L 12,-6 Z",
fill: "#5B8FF9"
}
},
labelCfg: {
autoRotate: true,
style: {
fill: '#333',
fill: "#333",
fontSize: 10,
fontFamily: 'Microsoft YaHei',
fontFamily: "Microsoft YaHei",
background: {
fill: '#fff',
fill: "#fff",
padding: [2, 4, 2, 4],
radius: 2
}
......@@ -181,9 +192,9 @@ const initNormalGraph = (layoutType, width, height) => {
},
nodeStateStyles: {
active: {
shadowColor: '#1459BB',
shadowColor: "#1459BB",
shadowBlur: 15,
stroke: '#1459BB',
stroke: "#1459BB",
lineWidth: 3
},
inactive: {
......@@ -192,44 +203,44 @@ const initNormalGraph = (layoutType, width, height) => {
},
edgeStateStyles: {
active: {
stroke: '#1459BB',
stroke: "#1459BB",
lineWidth: 4
},
inactive: {
opacity: 0.15
}
}
})
});
graphInstance.value.data(data)
graphInstance.value.render()
bindGraphEvents()
}
graphInstance.value.data(data);
graphInstance.value.render();
bindGraphEvents();
};
const initCircularGraph = (width, height) => {
const data = processGraphData(props.graphData)
const data = processGraphData(props.graphData);
if (!data.nodes || data.nodes.length === 0) return
if (!data.nodes || data.nodes.length === 0) return;
const centerX = width / 2
const centerY = height / 2
const radius = Math.min(width, height) / 2 - 120
const centerX = width / 2;
const centerY = height / 2;
const radius = Math.min(width, height) / 2 - 120;
const otherNodes = data.nodes.filter(n => !n.isCenter)
const nodeCount = otherNodes.length
const otherNodes = data.nodes.filter(n => !n.isCenter);
const nodeCount = otherNodes.length;
otherNodes.forEach((node, index) => {
const angle = (2 * Math.PI * index) / nodeCount - Math.PI / 2
node.x = centerX + radius * Math.cos(angle)
node.y = centerY + radius * Math.sin(angle)
})
const angle = (2 * Math.PI * index) / nodeCount - Math.PI / 2;
node.x = centerX + radius * Math.cos(angle);
node.y = centerY + radius * Math.sin(angle);
});
const centerNode = data.nodes.find(n => n.isCenter)
const centerNode = data.nodes.find(n => n.isCenter);
if (centerNode) {
centerNode.x = centerX
centerNode.y = centerY
centerNode.fx = centerX
centerNode.fy = centerY
centerNode.x = centerX;
centerNode.y = centerY;
centerNode.fx = centerX;
centerNode.fy = centerY;
}
graphInstance.value = new G6.Graph({
......@@ -241,40 +252,40 @@ const initCircularGraph = (width, height) => {
animate: true,
animateCfg: {
duration: 300,
easing: 'easeLinear'
easing: "easeLinear"
},
minZoom: 0.1,
maxZoom: 10,
modes: {
default: [
'drag-canvas',
'zoom-canvas',
'drag-node',
"drag-canvas",
"zoom-canvas",
"drag-node",
{
type: 'activate-relations',
trigger: 'mouseenter',
type: "activate-relations",
trigger: "mouseenter",
resetSelected: true
}
]
},
defaultNode: {
type: 'image',
type: "image",
size: 40,
clipCfg: {
show: true,
type: 'circle',
type: "circle",
r: 20
},
labelCfg: {
position: 'bottom',
position: "bottom",
offset: 10,
style: {
fill: '#333',
fill: "#333",
fontSize: 11,
fontFamily: 'Microsoft YaHei',
textAlign: 'center',
fontFamily: "Microsoft YaHei",
textAlign: "center",
background: {
fill: 'rgba(255, 255, 255, 0.95)',
fill: "rgba(255, 255, 255, 0.95)",
padding: [4, 6, 4, 6],
radius: 4
}
......@@ -282,24 +293,24 @@ const initCircularGraph = (width, height) => {
}
},
defaultEdge: {
type: 'quadratic',
type: "quadratic",
style: {
stroke: '#5B8FF9',
stroke: "#5B8FF9",
lineWidth: 3,
opacity: 0.9,
endArrow: {
path: 'M 0,0 L 12,6 L 12,-6 Z',
fill: '#5B8FF9'
path: "M 0,0 L 12,6 L 12,-6 Z",
fill: "#5B8FF9"
}
},
labelCfg: {
autoRotate: true,
style: {
fill: '#333',
fill: "#333",
fontSize: 10,
fontFamily: 'Microsoft YaHei',
fontFamily: "Microsoft YaHei",
background: {
fill: '#fff',
fill: "#fff",
padding: [2, 4, 2, 4],
radius: 2
}
......@@ -308,9 +319,9 @@ const initCircularGraph = (width, height) => {
},
nodeStateStyles: {
active: {
shadowColor: '#1459BB',
shadowColor: "#1459BB",
shadowBlur: 15,
stroke: '#1459BB',
stroke: "#1459BB",
lineWidth: 3
},
inactive: {
......@@ -319,24 +330,24 @@ const initCircularGraph = (width, height) => {
},
edgeStateStyles: {
active: {
stroke: '#1459BB',
stroke: "#1459BB",
lineWidth: 4
},
inactive: {
opacity: 0.15
}
}
})
});
graphInstance.value.data(data)
graphInstance.value.render()
bindGraphEvents()
}
graphInstance.value.data(data);
graphInstance.value.render();
bindGraphEvents();
};
const initTreeGraph = (width, height) => {
const treeDataSource = convertGraphToTree(props.graphData)
const treeDataSource = convertGraphToTree(props.graphData);
if (!treeDataSource) return
if (!treeDataSource) return;
graphInstance.value = new G6.TreeGraph({
container: containerRef.value,
......@@ -347,61 +358,61 @@ const initTreeGraph = (width, height) => {
animate: true,
animateCfg: {
duration: 300,
easing: 'easeLinear'
easing: "easeLinear"
},
minZoom: 0.1,
maxZoom: 10,
modes: {
default: [
'drag-canvas',
'zoom-canvas',
'drag-node',
"drag-canvas",
"zoom-canvas",
"drag-node",
{
type: 'collapse-expand',
type: "collapse-expand",
onChange: function onChange(item, collapsed) {
const data = item.getModel()
data.collapsed = collapsed
return true
const data = item.getModel();
data.collapsed = collapsed;
return true;
}
}
]
},
layout: {
type: 'compactBox',
direction: 'LR',
type: "compactBox",
direction: "LR",
getId: function getId(d) {
return d.id
return d.id;
},
getHeight: function getHeight() {
return 16
return 16;
},
getWidth: function getWidth() {
return 16
return 16;
},
getVGap: function getVGap() {
return 30
return 30;
},
getHGap: function getHGap() {
return 120
return 120;
}
},
defaultNode: {
type: 'image',
type: "image",
size: 40,
clipCfg: {
show: true,
type: 'circle',
type: "circle",
r: 20
},
labelCfg: {
position: 'right',
position: "right",
offset: 10,
style: {
fill: '#333',
fill: "#333",
fontSize: 11,
fontFamily: 'Microsoft YaHei',
fontFamily: "Microsoft YaHei",
background: {
fill: 'rgba(255, 255, 255, 0.95)',
fill: "rgba(255, 255, 255, 0.95)",
padding: [4, 6, 4, 6],
radius: 4
}
......@@ -409,262 +420,288 @@ const initTreeGraph = (width, height) => {
}
},
defaultEdge: {
type: 'cubic-horizontal',
type: "cubic-horizontal",
style: {
stroke: '#5B8FF9',
stroke: "#5B8FF9",
lineWidth: 3
}
},
nodeStateStyles: {
active: {
shadowColor: '#1459BB',
shadowColor: "#1459BB",
shadowBlur: 15,
stroke: '#1459BB',
stroke: "#1459BB",
lineWidth: 3
}
}
})
});
graphInstance.value.data(treeDataSource)
graphInstance.value.render()
graphInstance.value.fitView()
bindGraphEvents()
}
graphInstance.value.data(treeDataSource);
graphInstance.value.render();
graphInstance.value.fitView();
bindGraphEvents();
};
const convertGraphToTree = (graphData) => {
const convertGraphToTree = graphData => {
if (!graphData || !graphData.nodes || graphData.nodes.length === 0) {
return null
return null;
}
const nodes = graphData.nodes
const links = graphData.links || graphData.edges || []
const centerNode = nodes[0]
const centerId = String(centerNode.id || '0')
const nodes = graphData.nodes;
const links = graphData.links || graphData.edges || [];
const centerNode = nodes[0];
const centerId = String(centerNode.id || "0");
const childIdSet = new Set()
const childrenNodes = []
const childIdSet = new Set();
const childrenNodes = [];
links.forEach((link) => {
const source = String(link.source)
const target = String(link.target)
links.forEach(link => {
const source = String(link.source);
const target = String(link.target);
if (source === centerId && !childIdSet.has(target)) {
const node = nodes.find(n => String(n.id) === target)
const node = nodes.find(n => String(n.id) === target);
if (node) {
childIdSet.add(target)
childIdSet.add(target);
childrenNodes.push({
id: target,
label: node.name || '',
label: node.name || "",
img: node.image || defaultIcon,
size: node.symbolSize || 40,
name: node.name,
image: node.image,
isSanctioned: node.isSanctioned
})
});
}
} else if (target === centerId && !childIdSet.has(source)) {
const node = nodes.find(n => String(n.id) === source)
const node = nodes.find(n => String(n.id) === source);
if (node) {
childIdSet.add(source)
childIdSet.add(source);
childrenNodes.push({
id: source,
label: node.name || '',
label: node.name || "",
img: node.image || defaultIcon,
size: node.symbolSize || 40,
name: node.name,
image: node.image,
isSanctioned: node.isSanctioned
})
});
}
}
})
});
if (childrenNodes.length === 0) {
nodes.slice(1).forEach((node) => {
const nodeId = String(node.id)
nodes.slice(1).forEach(node => {
const nodeId = String(node.id);
if (!childIdSet.has(nodeId)) {
childIdSet.add(nodeId)
childIdSet.add(nodeId);
childrenNodes.push({
id: nodeId,
label: node.name || '',
label: node.name || "",
img: node.image || defaultIcon,
size: node.symbolSize || 40,
name: node.name,
image: node.image,
isSanctioned: node.isSanctioned
})
});
}
})
});
}
return {
id: centerId,
label: centerNode.name || '',
label: centerNode.name || "",
img: centerNode.image || defaultIcon,
size: centerNode.symbolSize || 60,
name: centerNode.name,
image: centerNode.image,
isSanctioned: centerNode.isSanctioned,
children: childrenNodes
}
}
};
};
const processGraphData = (rawData) => {
const processGraphData = rawData => {
if (!rawData || !rawData.nodes || rawData.nodes.length === 0) {
return { nodes: [], edges: [] }
return { nodes: [], edges: [] };
}
const nodeMap = new Map()
const nodes = []
const nodeMap = new Map();
const nodes = [];
rawData.nodes.forEach((node, index) => {
const nodeId = String(node.id || index)
const nodeId = String(node.id || index);
if (nodeMap.has(nodeId)) {
return
return;
}
nodeMap.set(nodeId, true)
nodeMap.set(nodeId, true);
const isCenter = index === 0
const size = node.symbolSize || (isCenter ? 60 : 40)
const isCenter = index === 0;
const size = node.symbolSize || (isCenter ? 60 : 40);
// 根据 isSanctioned 设置节点边框
const strokeColor = !node.isSanctioned ? "rgba(231, 243, 255, 1)" : "transparent";
const lineWidth = !node.isSanctioned ? 10 : 0;
nodes.push({
id: nodeId,
label: node.name || '',
label: node.name || "",
img: node.image || defaultIcon,
size,
isCenter,
clipCfg: {
show: true,
type: 'circle',
type: "circle",
r: size / 2
},
style: {
cursor: 'pointer'
cursor: "pointer",
stroke: strokeColor,
lineWidth: lineWidth
},
labelCfg: {
position: 'bottom',
position: "bottom",
offset: 12,
style: {
fill: isCenter ? '#1459BB' : '#333',
fill: isCenter ? "#1459BB" : "#333",
fontSize: isCenter ? 13 : 11,
fontWeight: isCenter ? 'bold' : 'normal',
fontFamily: 'Microsoft YaHei',
textAlign: 'center'
fontWeight: isCenter ? "bold" : "normal",
fontFamily: "Microsoft YaHei",
textAlign: "center"
}
},
...node,
id: nodeId
})
})
});
});
const edgeMap = new Map()
const edges = []
const edgeMap = new Map();
const edges = [];
const rawEdges = rawData.links || rawData.edges || []
const rawEdges = rawData.links || rawData.edges || [];
rawEdges.forEach((edge, index) => {
const source = String(edge.source)
const target = String(edge.target)
const edgeKey = `${source}-${target}`
const source = String(edge.source);
const target = String(edge.target);
const edgeKey = `${source}-${target}`;
if (edgeMap.has(edgeKey)) {
return
return;
}
if (!nodeMap.has(source) || !nodeMap.has(target)) {
return
return;
}
edgeMap.set(edgeKey, true)
edgeMap.set(edgeKey, true);
edges.push({
id: `edge-${index}`,
source,
target,
label: edge.name || ''
})
})
label: edge.name || "",
// 添加边的边框效果(使用 shadow 模拟)
style: {
stroke: "#5B8FF9",
lineWidth: 3,
opacity: 0.9,
shadowColor: "rgba(231, 243, 255, 1)",
shadowBlur: 4,
endArrow: {
path: "M 0,0 L 12,6 L 12,-6 Z",
fill: "#5B8FF9"
}
}
});
});
return { nodes, edges }
}
return { nodes, edges };
};
const bindGraphEvents = () => {
if (!graphInstance.value) return
graphInstance.value.on('node:click', (evt) => {
const node = evt.item
const model = node.getModel()
selectedNode.value = model
emit('nodeClick', model)
})
graphInstance.value.on('canvas:click', () => {
selectedNode.value = null
})
}
const handleClickControlBtn = (btn) => {
currentLayoutType.value = btn
emit('layoutChange', btn)
initGraph(btn)
}
if (!graphInstance.value) return;
graphInstance.value.on("node:click", evt => {
const node = evt.item;
const model = node.getModel();
console.log("点击节点model", model);
const id = model.id.split("-")[1];
let desList = [];
getSingleSanctionEntityInfo(id).then(res => {
console.log("制裁实体信息", res);
if (res.data.sanInfoList && res.data.sanInfoList.length > 0) {
desList = res.data.sanInfoList.map(item => item.sanReason);
}
});
selectedNode.value = { ...model, description: desList };
emit("nodeClick", model);
});
graphInstance.value.on("canvas:click", () => {
selectedNode.value = null;
});
};
const handleClickControlBtn = btn => {
currentLayoutType.value = btn;
emit("layoutChange", btn);
initGraph(btn);
};
const destroyGraph = () => {
if (graphInstance.value) {
graphInstance.value.destroy()
graphInstance.value = null
graphInstance.value.destroy();
graphInstance.value = null;
}
}
};
const handleResize = () => {
if (graphInstance.value && containerRef.value) {
const width = containerRef.value.offsetWidth
const height = containerRef.value.offsetHeight
graphInstance.value.changeSize(width, height)
graphInstance.value.fitView()
const width = containerRef.value.offsetWidth;
const height = containerRef.value.offsetHeight;
graphInstance.value.changeSize(width, height);
graphInstance.value.fitView();
}
}
};
watch(
() => props.graphData,
() => {
initGraph(currentLayoutType.value)
initGraph(currentLayoutType.value);
},
{ deep: true }
)
);
watch(
() => props.treeData,
() => {
if (currentLayoutType.value === 2) {
initGraph(2)
initGraph(2);
}
},
{ deep: true }
)
);
watch(
() => props.controlActive,
(newVal) => {
newVal => {
if (newVal !== currentLayoutType.value) {
handleClickControlBtn(newVal)
handleClickControlBtn(newVal);
}
}
)
);
onMounted(() => {
initGraph(1)
window.addEventListener('resize', handleResize)
})
initGraph(1);
window.addEventListener("resize", handleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
destroyGraph()
})
window.removeEventListener("resize", handleResize);
destroyGraph();
});
defineExpose({
refresh: () => initGraph(currentLayoutType.value),
changeLayout: (type) => handleClickControlBtn(type),
changeLayout: type => handleClickControlBtn(type),
getGraph: () => graphInstance.value
})
});
</script>
<style lang="scss" scoped>
......
......@@ -130,7 +130,7 @@ import companyActive from "./assets/company-active.png";
import {
getSingleSanctionEntityList,
getSingleSanctionEntitySupplyChain,
getSingleSanctionEntityEquity
getSingleSanctionEntityEquity,
} from "@/api/exportControlV2.0";
import RelationGraph from "./components/RelationGraph.vue";
import AnalysisBox from "@/components/base/BoxBackground/AnalysisBox.vue";
......@@ -888,9 +888,11 @@ onMounted(async () => {
margin-left: 16px;
.toggle-btn {
width: 108px;
height: 100%;
padding: 4px 16px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
transition: all 0.3s;
......
......@@ -90,6 +90,37 @@
<div class="right" v-loading="isLoading">
<AnalysisBox title="制裁清单" :showAllBtn="false">
<div class="right-title">
<div class="filter-row">
<div class="filter-left">
<el-select v-model="filterEntity" placeholder="受制裁实体" style="width: 184px">
<el-option label="受制裁实体" value="2" />
<el-option label="受制裁地址" value="7" />
<el-option label="受制裁个人" value="1" />
</el-select>
</div>
<div class="filter-right">
<el-checkbox v-model="onlyChina" label="只看中国实体" />
<el-select
v-model="filterField"
placeholder="全部领域"
style="width: 150px; margin: 0 12px 0 16px"
>
<el-option label="全部领域" value="" />
<el-option
v-for="item in domainOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-input
v-model="searchKeyword"
placeholder="搜索实体"
style="width: 150px"
:suffix-icon="Search"
/>
</div>
</div>
<div class="stats-row">
<div class="tabs">
<div class="tab-btn" :class="{ active: activeTab === 'add' }" @click="activeTab = 'add'">
......@@ -120,35 +151,6 @@
</div>
</div>
</div>
<div class="filter-row">
<div class="filter-left">
<!-- <el-select v-model="filterEntity" placeholder="受制裁实体" style="width: 184px">
<el-option label="受制裁实体" value="1" />
</el-select> -->
</div>
<div class="filter-right">
<el-checkbox v-model="onlyChina" label="只看中国实体" />
<el-select
v-model="filterField"
placeholder="全部领域"
style="width: 150px; margin: 0 12px 0 16px"
>
<el-option label="全部领域" value="" />
<el-option
v-for="item in domainOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-input
v-model="searchKeyword"
placeholder="搜索实体"
style="width: 150px"
:suffix-icon="Search"
/>
</div>
</div>
</div>
<div class="right-content">
<div class="sanction-group-list">
......@@ -286,7 +288,8 @@ const getSanctionOverviewList = async () => {
isOnlyCn: onlyChina.value,
domainId: filterField.value || undefined,
searchText: searchKeyword.value || undefined,
searchType: searchType.value
searchType: searchType.value,
entityTypeId: filterEntity.value || undefined
});
isLoading.value = false;
if (res.code === 200) {
......@@ -446,7 +449,7 @@ const formattedData = computed(() => {
};
});
const filterEntity = ref("");
const filterEntity = ref("2");
const onlyChina = ref(false);
const filterField = ref("");
const searchKeyword = ref("");
......@@ -454,7 +457,7 @@ const activeTab = ref("add");
const searchType = computed(() => activeTab.value);
// 监听筛选条件变化
watch([onlyChina, filterField, activeTab], () => {
watch([onlyChina, filterField, activeTab, filterEntity], () => {
getSanctionOverviewList();
});
......@@ -865,9 +868,9 @@ onMounted(() => {
.filter-row {
display: flex;
justify-content: right;
justify-content: space-between;
align-items: center;
// margin-bottom: 20px;
margin-bottom: 20px;
:deep(.el-input__inner) {
font-size: 16px;
......@@ -897,7 +900,7 @@ onMounted(() => {
.stats-row {
display: flex;
margin-bottom: 20px;
// margin-bottom: 20px;
justify-content: space-between;
align-items: center;
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论