提交 a1036c92 authored 作者: coderBryanFu's avatar coderBryanFu

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

流水线 #412 已通过 于阶段
in 1 分 30 秒
const baseUrl = `http://8.140.26.4:9085`
const outImgbaseUrl = `http://172.19.21.9:8003/out/img` // 写报图片URL
\ No newline at end of file
const outImgbaseUrl = `http://8.140.26.4:10017/out/img` // 写报图片URL
\ No newline at end of file
import { getToken } from "@/api/request.js";
import { getToken, formatBearerAuthorization } from "@/api/request.js";
const CHART_INTERPRETATION_URL = "/aiAnalysis/chart_interpretation";
const API_KEY = "aircasKEY19491001";
......@@ -183,6 +183,8 @@ export function getChartAnalysis(data, options = {}) {
(async () => {
try {
const { fetchEventSource } = await import("@microsoft/fetch-event-source");
const sseRaw = getToken();
const sseToken = sseRaw ? String(sseRaw).trim() : "";
await fetchEventSource(CHART_INTERPRETATION_URL, {
method: "POST",
......@@ -191,8 +193,7 @@ export function getChartAnalysis(data, options = {}) {
Accept: "text/event-stream",
"Cache-Control": "no-cache",
"X-API-Key": API_KEY,
// 后端同项目其它接口使用 token 字段名(axios 拦截器里就是这样注入的)
token: getToken()
...(sseToken ? { token: sseToken } : {})
},
body: JSON.stringify(data),
signal: abortController.signal,
......
......@@ -16,7 +16,7 @@ export function getBillIndustry(params) {
return request({
method: 'GET',
url: `/api/BillOverview/billIndustry/${params.year}`,
params: { status: params.status }
params: { stageName: params.stageName }
})
}
......
......@@ -498,14 +498,15 @@ export function getEntitiesUpdateCount(sanTypeId = 1) {
* @param {string} rule - 规则
* @param {string} type - 类型
*/
export function getSanDomainCount(rule, type) {
export function getSanDomainCount(rule, sanTypeIds, type) {
return request200(
request({
method: "GET",
url: "/api/entitiesDataCount/getSanDomainCount",
params: {
rule,
type
sanTypeIds
// type
}
})
);
......
......@@ -136,3 +136,27 @@ export function getRelateNews(sanRecordId) {
export function getReasonAndSan(sanRecordId) {
return http.get(`/api/sanctionList/invFin/getReasonAndSan?sanRecordId=${sanRecordId}`);
}
/**
* 制裁历程
* url:/entitiesDataCount/getSanRecord
*/
export function getSanRecord(params) {
return http.get("/api/entitiesDataCount/getSanRecord", params);
}
/**
* 限制关系分析-限制举措关系图
* url:/sanctionList/invFin/recordRelation
*/
export function getRecordRelation(sanRecordIds) {
return http.get(`/api/sanctionList/invFin/recordRelation?sanRecordIds=${sanRecordIds}`);
}
/**
* 查询投融资限制关联-图谱-节点详情
* url:/sanctionList/invFin/getVertexInfo
*/
export function getVertexInfo(sanRecordId) {
return http.get(`/api/sanctionList/invFin/getVertexInfo?sanRecordId=${sanRecordId}`);
}
\ No newline at end of file
......@@ -2,27 +2,8 @@
import axios from 'axios'
// 引入 element-plus 里面的消息提示
import { ElMessage } from 'element-plus'
import { getToken, setToken, removeToken, formatBearerAuthorization } from '@/api/request.js'
// Token 管理
const TOKEN_KEY = 'auth_token'
// 获取token
const getToken = () => {
return 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJkYXRhLWNlbnRlciIsImF1ZCI6IndlYiIsImlzcyI6ImRhdGEtY2VudGVyIiwiZXhwIjozODI1ODM1NTkxLCJpYXQiOjE2NzgzNTE5NTMsImp0aSI6IjI4YmY1NTZjMTc0MDQ3YjJiNTExNWM3NzVhYjhlNWRmIiwidXNlcm5hbWUiOiJzdXBlcl91c2VyIn0.zHyVzsleX2lEqjDBYRpwluu_wy2nZKGl0dw3IUGnKNw'
// return localStorage.getItem(TOKEN_KEY)
}
// 设置 token
const setToken = (token) => {
localStorage.setItem(TOKEN_KEY, token)
}
// 移除 token
const removeToken = () => {
localStorage.removeItem(TOKEN_KEY)
}
// 导出 token 管理方法
export { getToken, setToken, removeToken }
// 创建 axios 实例
......@@ -30,19 +11,35 @@ const service = axios.create({
timeout: 300 * 1000 // 请求超时时间
})
// request 拦截器
// request 拦截器:与主 request 一致,就地改 config,避免 mergeConfig 破坏 url
service.interceptors.request.use(config => {
// 获取 token 并添加到请求头
const token = getToken()
const raw = getToken()
const token = raw ? String(raw).trim() : ""
if (!config.headers) {
config.headers = new axios.AxiosHeaders()
} else if (!(config.headers instanceof axios.AxiosHeaders)) {
config.headers = axios.AxiosHeaders.from(config.headers)
}
if (token) {
config.headers['token'] = token
config.headers.set('token', token, true)
} else {
config.headers.delete('token')
config.headers.delete('Token')
// ===== 旧逻辑保留(勿删):Authorization: Bearer <token> =====
// config.headers.delete('Authorization')
// config.headers.delete('authorization')
}
// 图表解读等 AI 分析服务(Vite 代理 /aiAnalysis)需要 X-API-Key
const reqUrl = String(config.url || '')
const reqUrl = String(config.url ?? '')
if (reqUrl.includes('aiAnalysis')) {
const aiApiKey = import.meta.env.VITE_AI_ANALYSIS_API_KEY
if (aiApiKey) {
config.headers['X-API-Key'] = aiApiKey
if (!config.headers) {
config.headers = new axios.AxiosHeaders()
} else if (!(config.headers instanceof axios.AxiosHeaders)) {
config.headers = axios.AxiosHeaders.from(config.headers)
}
config.headers.set('X-API-Key', aiApiKey)
}
}
return config
......@@ -54,7 +51,10 @@ service.interceptors.request.use(config => {
// response 拦截器
service.interceptors.response.use(
response => {
const res = response.data
const res = response?.data
if (!res) {
return Promise.reject(new Error('响应数据为空'))
}
// 根据需求:接口返回 code 不等于 200 的时候报错
if (res.code !== 200) {
ElMessage({
......@@ -90,10 +90,20 @@ service.interceptors.response.use(
type: 'error',
duration: 3 * 1000
})
removeToken()
const h = error.config?.headers
const hadToken =
h &&
(typeof h.get === 'function'
? Boolean(
h.get('token') || h.get('Token')
)
: Boolean(
h.token || h.Token
))
if (hadToken) removeToken()
} else {
ElMessage({
message: error.message,
message: typeof error?.message === 'string' ? error.message : '请求失败',
type: 'error',
duration: 3 * 1000
})
......
......@@ -8,8 +8,19 @@ import {
// Token管理
const TOKEN_KEY = 'auth_token'
// ===== 兼容导出(勿删):历史代码仍会 import formatBearerAuthorization =====
// 说明:当前线上版本后端用 `token` 头,不用 Authorization;但为了不影响其它模块编译/运行,这里保留该方法导出。
const formatBearerAuthorization = (raw) => {
const v = String(raw || "").trim()
if (!v) return ""
if (/^Bearer\s+/i.test(v)) return v
return `Bearer ${v}`
}
// ===== 兼容导出(勿删)=====
// 获取token
const getToken = () => {
// 固定 token(恢复原行为:所有请求都带这个 token)
return 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJkYXRhLWNlbnRlciIsImF1ZCI6IndlYiIsImlzcyI6ImRhdGEtY2VudGVyIiwiZXhwIjozODI1ODM1NTkxLCJpYXQiOjE2NzgzNTE5NTMsImp0aSI6IjI4YmY1NTZjMTc0MDQ3YjJiNTExNWM3NzVhYjhlNWRmIiwidXNlcm5hbWUiOiJzdXBlcl91c2VyIn0.zHyVzsleX2lEqjDBYRpwluu_wy2nZKGl0dw3IUGnKNw'
// return localStorage.getItem(TOKEN_KEY)
}
......@@ -25,7 +36,7 @@ const removeToken = () => {
}
// 导出token管理方法
export { getToken, setToken, removeToken }
export { getToken, setToken, removeToken, formatBearerAuthorization }
// const BASE_API = import.meta.env.VITE_BASE_API
// 创建axios实例
......@@ -89,6 +100,28 @@ service.interceptors.response.use(
// 重复请求触发的取消不提示错误
if (isCanceledError) return Promise.reject(error)
// 特殊处理:用户信息接口可能在某些环境不存在(404),此时不弹出错误提示
// 需求:GET /api/sso/user/info 返回 404 时静默
try {
const errUrl = String(error?.config?.url || '')
if (error?.response?.status === 404 && errUrl.includes('/api/sso/user/info')) {
return Promise.reject(error)
}
} catch {
// ignore
}
// 特殊处理:风险信号管理页面接口偶发 500,不弹出提示
// 覆盖接口:/api/riskSignal/getCountInfo | /api/riskSignal/getDailyCount | /api/riskSignal/pageQuery
try {
const errUrl = String(error?.config?.url || '')
if (error?.response?.status === 500 && errUrl.includes('/api/riskSignal/')) {
return Promise.reject(error)
}
} catch {
// ignore
}
// 处理token过期或无效的情况(排除 AI 分析服务:其 401 多为 API Key 问题)
const errUrl = String(error.config?.url || '')
......@@ -118,4 +151,12 @@ service.interceptors.response.use(
}
)
export default service
\ No newline at end of file
export default service
/*
===========================
下面是“当前新版 request.js”代码备份(按你的要求:不删除,仅注释保留)
===========================
(此处保留了之前实现的 token 多来源读取、AxiosHeaders、默认头同步、401 防抖等逻辑)
*/
\ No newline at end of file
// 智库概览信息
import request, { getToken } from "@/api/request.js";
import request, { getToken, formatBearerAuthorization } from "@/api/request.js";
// 智库列表
export function getThinkTankList() {
......@@ -257,7 +257,8 @@ export function postReportDomainViewAnalysis(data) {
*/
export async function postReportDomainViewAnalysisStream(data, handlers = {}) {
const { onReasoningChunk, onMessage } = handlers
const token = getToken()
const raw = getToken()
const token = raw ? String(raw).trim() : ""
const response = await fetch('/intelligent-api/report-domain-view-analysis', {
method: 'POST',
headers: {
......
/**
* 必须在 router、业务组件之前执行,且不能 import @/api/request(会提前加载 element-plus,易触发 Vue 初始化异常)。
* 与 request.js 中 TOKEN_KEY / TOKEN_SESSION_KEY 保持一致。
*/
const TOKEN_KEY = "auth_token";
const TOKEN_SESSION_KEY = "auth_token";
if (typeof window !== "undefined") {
try {
const q = new URLSearchParams(window.location.search || "");
const t = q.get("token");
if (t && String(t).trim()) {
const v = String(t).trim();
localStorage.setItem(TOKEN_KEY, v);
sessionStorage.setItem(TOKEN_SESSION_KEY, v);
}
} catch {
// ignore
}
}
<template>
<div class="summary-cards-panel" :style="{ width: panelWidth }" v-loading="loading">
<div class="date-box">
<div class="date-icon" v-if="tipIcon">
<img :src="tipIcon" alt="" />
</div>
<div class="date-text">{{ descriptionText }}</div>
<TimeTabPane @time-click="onTimeClick" :activeTime="activeTime" />
</div>
<div class="cards-box" v-if="cards.length">
<div class="summary-item" v-for="item in cards" :key="item.id || item.name">
<div class="item-left">
<img :src="item.avatar || defaultAvatar" alt="" />
</div>
<div class="item-center" @click="emit('name-click', item)">
<div class="item-name one-line-ellipsis">{{ item.name }}</div>
<div v-if="item.subText" class="item-sub-text one-line-ellipsis">{{ item.subText }}</div>
</div>
<el-popover content="跳转至数据资源库" placement="top">
<template #reference>
<div class="item-total" @click="emit('count-click', item)">{{ item.count }}{{ countSuffix }}</div>
</template>
</el-popover>
<el-icon color="var(--color-primary-100)">
<ArrowRightBold />
</el-icon>
<div class="item-dot" v-if="item.delta">+{{ item.delta }}</div>
</div>
<div v-if="shouldShowMoreCard" class="summary-item" @click="emit('more-click')">
<div class="item-more">{{ moreText }} ({{ totalCount }}家)</div>
<el-icon color="var(--color-primary-100)">
<ArrowRightBold />
</el-icon>
</div>
</div>
<div v-else class="cards-empty">
<el-empty :description="emptyText" :image-size="80" />
</div>
</div>
</template>
<script setup>
import TimeTabPane from "@/components/base/TimeTabPane/index.vue";
import { ArrowRightBold } from "@element-plus/icons-vue";
import { computed } from "vue";
const props = defineProps({
descriptionText: {
type: String,
default: ""
},
cards: {
type: Array,
default: () => []
},
totalCount: {
type: Number,
default: 0
},
showMoreCard: {
type: Boolean,
default: true
},
moreCardMinCount: {
type: Number,
default: 8
},
emptyText: {
type: String,
default: "暂无数据"
},
activeTime: {
type: String,
default: "近一年"
},
tipIcon: {
type: String,
default: ""
},
defaultAvatar: {
type: String,
default: ""
},
countSuffix: {
type: String,
default: "项"
},
moreText: {
type: String,
default: "查看全部机构"
},
panelWidth: {
type: String,
default: "1600px"
},
loading: {
type: Boolean,
default: false
}
});
const emit = defineEmits(["time-click", "name-click", "count-click", "more-click"]);
const shouldShowMoreCard = computed(() => {
if (!props.showMoreCard) return false;
if (props.moreCardMinCount <= 0) return true;
return (props.cards?.length || 0) >= props.moreCardMinCount;
});
const onTimeClick = event => {
emit("time-click", event);
};
</script>
<style scoped lang="scss">
.summary-cards-panel {
margin: 48px auto 0;
.date-box {
display: flex;
align-items: center;
.date-icon {
width: 16px;
height: 16px;
font-size: 0;
margin-right: 6px;
img {
width: 100%;
height: 100%;
}
}
.date-text {
width: 20px;
flex: auto;
font-size: 18px;
line-height: 18px;
font-family: Source Han Sans CN;
color: var(--text-primary-80-color);
}
}
.cards-box {
margin: 20px 0 64px;
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-auto-rows: 80px;
gap: 16px;
font-family: Microsoft YaHei;
.summary-item {
padding: 0 16px;
display: flex;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.65);
border: 1px solid rgba(255, 255, 255, 1);
border-radius: 10px;
box-shadow: 0 0 20px 0 rgba(25, 69, 130, 0.1);
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 0.3s ease, box-shadow 0.3s ease;
position: relative;
&:hover {
transform: translateY(-3px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.item-left {
width: 48px;
height: 48px;
font-size: 0;
img {
width: 100%;
height: 100%;
}
}
.item-center {
width: 20px;
flex: auto;
margin: 0 16px;
cursor: pointer;
}
.item-name {
color: rgba(59, 65, 75, 1);
font-size: 20px;
font-weight: 700;
line-height: 20px;
&:hover {
color: var(--color-primary-100);
text-decoration: underline;
}
}
.item-sub-text {
margin-top: 6px;
color: var(--text-primary-50-color);
font-size: 14px;
line-height: 18px;
}
.item-total {
font-size: 20px;
margin-right: 2px;
white-space: nowrap;
font-weight: 700;
line-height: 20px;
color: var(--color-primary-100);
&:hover {
text-decoration: underline;
}
}
.item-more {
font-size: 16px;
margin-right: 12px;
white-space: nowrap;
font-weight: 700;
line-height: 16px;
color: var(--color-primary-100);
}
.item-dot {
position: absolute;
right: -13px;
top: -10px;
padding: 0 8px;
height: 26px;
background-color: #ff4d4f;
color: white;
font-size: 16px;
line-height: 26px;
font-family: Source Han Sans CN;
border-radius: 14px;
letter-spacing: 1px;
}
}
}
.cards-empty {
margin: 20px 0 64px;
height: 120px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.65);
border: 1px solid rgba(255, 255, 255, 1);
border-radius: 10px;
box-shadow: 0 0 20px 0 rgba(25, 69, 130, 0.1);
}
}
</style>
......@@ -20,14 +20,37 @@
</div>
<div class="nav-right">
<div class="info-box" @click="handleClickToolBox">
<div class="mail">
<div class="info-box">
<div class="mail" @click="handleClickToolBox">
<img src="@/assets/icons/overview/mail.png" alt="" />
</div>
<div class="user">
<img src="@/assets/icons/overview/user.png" alt="" />
<div class="user-trigger">
<div class="user" @click.stop="handleToggleUserPanel">
<img src="@/assets/icons/overview/user.png" alt="" />
</div>
</div>
<div v-if="isShowUserPanel" class="user-panel">
<div class="user-panel-row">
<div class="blue-solid"></div>
<span class="user-panel-value user-panel-value--nickname">{{ userNickname }}</span>
<div class="role-box">
<span class="user-panel-value user-panel-value--role">{{ roleName }}</span>
</div>
</div>
<div class="user-panel-row user-panel-row--single">
<span class="user-panel-value user-panel-value--organ">{{ organName }}</span>
</div>
<div class="solid"></div>
<div class="user-panel-logout" @click.stop="handleUserCommand('logout')"><img src="./back.png"
class="back-image" />{{ "退出登录" }}
</div>
</div>
<div class="name text-regular">{{ "管理员" }}</div>
</div>
</div>
<div class="menu-box" v-show="isShowMenu" @mouseenter="handleHoverMenu(true)"
......@@ -61,6 +84,7 @@ import { ref, computed, onMounted, watchEffect, onUnmounted } from "vue";
import { useRouter } from "vue-router";
import { useRoute } from "vue-router";
import { getPersonType } from "@/api/common/index";
import request, { removeToken } from "@/api/request.js";
import SearchBar from "@/components/layout/SearchBar.vue";
import Menu1 from "@/assets/icons/overview/menu1.png";
......@@ -87,6 +111,16 @@ const router = useRouter();
const route = useRoute()
const personTypeList = ref([]);
const ssoUserInfo = ref(null);
const isShowUserPanel = ref(false);
// 用户面板展示兜底:接口无数据时不显示 "-",改为默认文案
const userNickname = computed(() => ssoUserInfo.value?.userInfo?.userNickname || "管理员");
const organName = computed(() => ssoUserInfo.value?.organInfo?.organName || "开发部门");
const roleName = computed(() => {
const roles = ssoUserInfo.value?.roles;
return Array.isArray(roles) && roles.length ? (roles[0]?.roleName || "系统管理员") : "系统管理员";
});
// 获取人物类别
const handleGetPersonType = async () => {
......@@ -102,6 +136,21 @@ const handleGetPersonType = async () => {
} catch (error) { }
};
// 获取当前登录用户信息(不展示,仅缓存;Authorization 由全局 request 拦截器自动注入)
const handleGetSsoUserInfo = async () => {
try {
const res = await request({
method: "GET",
// 通过 Vite 代理:/api -> 172.19.21.228:28080(rewrite 去掉 /api,后端收到 /sso/user/info)
url: "/api/sso/user/info"
});
ssoUserInfo.value = res?.data ?? null;
console.log("用户信息", ssoUserInfo.value);
} catch {
ssoUserInfo.value = null;
}
};
// 概览页标题列表
const homeTitleList = ref([
......@@ -354,8 +403,93 @@ const handleClickToolBox = () => {
ElMessage.warning("当前功能正在开发中,敬请期待!");
};
const handleToggleUserPanel = () => {
isShowUserPanel.value = !isShowUserPanel.value;
};
const handleHideUserPanel = () => {
isShowUserPanel.value = false;
};
const handleUserCommand = async (command) => {
if (command === "logout") {
isShowUserPanel.value = false;
// ===== 退出登录(外部重定向版,切线上接口时先注释保留) =====
// const ssoOrigin = import.meta.env.VITE_SSO_GATEWAY_ORIGIN || "http://172.19.21.228:28080";
// try {
// // 走后端登出接口,返回统一登出页地址(能清 SSO Cookie,确保下次必须重新登录)
// const res = await request({
// method: "POST",
// // 通过 Vite 代理:/api -> 172.19.21.228:28080(rewrite 去掉 /api,后端收到 /sso/logout)
// url: "/api/sso/logout"
// });
//
// // 无论成功失败,都清掉本地 token(确保后续请求不再带 Authorization)
// removeToken();
// try {
// window.localStorage.removeItem("auth_token");
// } catch {
// // ignore
// }
// try {
// window.sessionStorage.removeItem("auth_token");
// } catch {
// // ignore
// }
// try {
// window.sessionStorage.setItem("force_sso_login", "1");
// } catch {
// // ignore
// }
//
// const redirectUrl = res && typeof res.data === "string" ? res.data : "";
// window.location.replace(redirectUrl || `${ssoOrigin}/api/v2`);
// } catch (e) {
// // 兜底:清本地并跳网关
// removeToken();
// try {
// window.localStorage.removeItem("auth_token");
// } catch {
// // ignore
// }
// try {
// window.sessionStorage.removeItem("auth_token");
// } catch {
// // ignore
// }
// try {
// window.sessionStorage.setItem("force_sso_login", "1");
// } catch {
// // ignore
// }
// window.location.replace(`${ssoOrigin}/api/v2`);
// }
// ===== 退出登录(站内 login 版:清 token 后进入 /login) =====
removeToken();
try {
window.localStorage.removeItem("auth_token");
} catch {
// ignore
}
// 退出后强制所有页面回到登录页(跨标签页/刷新生效)
try {
window.localStorage.setItem("force_login", "1");
} catch {
// ignore
}
try {
window.sessionStorage.removeItem("auth_token");
} catch {
// ignore
}
router.replace("/login");
}
};
onMounted(() => {
handleGetPersonType();
handleGetSsoUserInfo();
});
</script>
......@@ -469,6 +603,8 @@ onMounted(() => {
display: flex;
justify-content: flex-end;
align-items: center;
position: relative;
.mail {
width: 32px;
......@@ -499,6 +635,159 @@ onMounted(() => {
height: 30px;
color: var(--text-primary-80-color);
}
.user-trigger {
display: flex;
align-items: center;
cursor: pointer;
}
.user-panel {
position: absolute;
right: 0;
top: calc(32px + 21px);
width: 240px;
height: 141px;
background: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(255, 255, 255, 1);
border-radius: 10px;
box-shadow: 0px 0px 20px 0px rgba(25, 69, 130, 0.1);
display: flex;
flex-direction: column;
// 高度固定 141px,需要把内容控制在容器内,避免分割线被裁剪
padding: 16px 15px 12px 24px;
box-sizing: border-box;
overflow: hidden;
z-index: 999999;
}
.user-panel-row {
display: flex;
align-items: center;
justify-content: space-between;
flex-direction: row;
gap: 12px;
position: relative;
padding-right: 3px;
}
.user-panel-row--single {
justify-content: flex-start;
}
.blue-solid {
position: absolute;
left: -24px;
width: 4px;
height: 48px;
background: rgb(5, 95, 194);
top: 0;
}
.solid {
width: 206px;
height: 1px;
background: rgb(234, 236, 238);
margin-top: 13px;
}
.user-panel-label {
color: rgba(59, 65, 75, 0.8);
font-size: 14px;
line-height: 20px;
flex-shrink: 0;
}
.user-panel-value {
font-family: "Source Han Sans CN";
font-weight: 400;
font-size: 14px;
line-height: 22px;
letter-spacing: 0px;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-panel-value--nickname {
font-family: "Source Han Sans CN";
font-weight: 700;
font-size: 18px;
line-height: 24px;
letter-spacing: 0px;
text-align: left;
color: rgb(59, 65, 75);
margin-left: 8px;
}
.role-box {
padding: 0px 8px;
background-color: rgb(231, 243, 255);
display: flex;
border-radius: 4px;
}
.user-panel-value--role {
font-family: "Source Han Sans CN";
font-weight: 400;
font-size: 14px;
line-height: 22px;
letter-spacing: 0px;
text-align: justify;
color: rgb(5, 95, 194);
}
.user-panel-value--organ {
font-family: "Source Han Sans CN";
font-weight: 400;
font-size: 14px;
line-height: 22px;
letter-spacing: 0px;
text-align: left;
color: rgb(95, 101, 108);
margin-top: 4px;
margin-left: 8px;
}
.user-panel-logout {
font-family: "Source Han Sans CN";
font-weight: 400;
font-size: 16px;
line-height: 24px;
letter-spacing: 0px;
text-align: justify;
color: rgb(59, 65, 75);
cursor: pointer;
height: 24px;
margin-top: 12px;
display: flex;
align-items: center;
margin-left: 8px;
.back-image {
width: 16px;
height: 16px;
margin-right: 12px;
img {
width: 100%;
height: 100%;
display: block;
}
}
}
}
}
......
<template>
<p class="p-regular-rereg">
<span class="text-regular" v-for="(segment, index) in processedText" :key="index">
<span v-if="segment.isEntity" @click="$emit('onEntityClick', segment.entity)" class="entity-link">
<span
v-if="segment.isEntity"
@click="$emit('onEntityClick', segment.entity)"
:class="['entity-link', { 'keyword-highlight': segment.isKeywordHit }]"
>
{{ segment.entity?.text_span }}
<img :src="SearchIcon" :width="10" :height="10" alt="search" />
</span>
<span v-else>
{{ segment.text }}
<span :class="{ 'keyword-highlight': segment.isKeywordHit }">{{ segment.text }}</span>
</span>
</span>
</p>
......@@ -20,6 +24,7 @@ export interface ProcessedTextSegment {
text: string;
isEntity: boolean;
entity?: TextEntity;
isKeywordHit?: boolean;
}
const props = defineProps({
text: {
......@@ -29,15 +34,42 @@ const props = defineProps({
entities: {
type: Array<TextEntity>,
default: () => []
},
highlight: {
type: String,
default: ""
}
});
const emit = defineEmits(["onEntityClick"]);
// 处理后的文本段
const processedText = ref<ProcessedTextSegment[]>([]);
const normalizeKeyword = (value: unknown) => String(value ?? "").trim();
const getKeywordMatches = (text: string, keyword: string) => {
if (!keyword) return [{ text, isKeywordHit: false }];
const lowerText = text.toLowerCase();
const lowerKeyword = keyword.toLowerCase();
if (!lowerKeyword) return [{ text, isKeywordHit: false }];
const parts: Array<{ text: string; isKeywordHit: boolean }> = [];
let start = 0;
while (start < text.length) {
const index = lowerText.indexOf(lowerKeyword, start);
if (index === -1) {
parts.push({ text: text.slice(start), isKeywordHit: false });
break;
}
if (index > start) {
parts.push({ text: text.slice(start, index), isKeywordHit: false });
}
parts.push({ text: text.slice(index, index + keyword.length), isKeywordHit: true });
start = index + keyword.length;
}
return parts.filter(part => part.text);
};
// 处理文本,识别并替换实体
const processText = () => {
console.log("props.entities.length", props.entities.length);
if (!props.text || !props.entities) {
// console.log('props.text', props.entities.length)
processedText.value = [{ text: "", isEntity: false }];
......@@ -46,6 +78,7 @@ const processText = () => {
const result = [];
let currentPosition = 0;
const keyword = normalizeKeyword(props.highlight);
// 按实体文本长度排序,优先匹配长文本
const sortedEntities = [...props.entities].sort((a, b) => b.text_span.length - a.text_span.length);
......@@ -61,7 +94,8 @@ const processText = () => {
// 如果当前位置是实体,添加到结果
result.push({
isEntity: true,
entity: { ...entity }
entity: { ...entity },
isKeywordHit: keyword ? entityText.toLowerCase().includes(keyword.toLowerCase()) : false
});
currentPosition = endPosition;
matched = true;
......@@ -82,18 +116,26 @@ const processText = () => {
if (nextEntityStart > currentPosition) {
const plainText = props.text.substring(currentPosition, nextEntityStart);
result.push({
text: plainText,
isEntity: false
const parts = getKeywordMatches(plainText, keyword);
parts.forEach(part => {
result.push({
text: part.text,
isEntity: false,
isKeywordHit: part.isKeywordHit
});
});
currentPosition = nextEntityStart;
} else {
// 没有更多实体,添加剩余文本
const remainingText = props.text.substring(currentPosition);
if (remainingText) {
result.push({
text: remainingText,
isEntity: false
const parts = getKeywordMatches(remainingText, keyword);
parts.forEach(part => {
result.push({
text: part.text,
isEntity: false,
isKeywordHit: part.isKeywordHit
});
});
}
currentPosition = props.text.length;
......@@ -106,6 +148,7 @@ const processText = () => {
// 监听文本和实体变化
watch(() => props.text, processText);
watch(() => props.entities, processText, { deep: true });
watch(() => props.highlight, processText);
// 初始化处理
onMounted(processText);
......@@ -113,6 +156,11 @@ onMounted(processText);
<style lang="scss" scoped>
@use "@/styles/common.scss";
.keyword-highlight {
background: rgba(255, 199, 0, 0.35);
border-radius: 2px;
}
.entity-link {
color: var(--color-primary-100);
&:hover {
......
import "./bootstrapAuth.js";
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
......
import { createRouter, createWebHistory } from "vue-router";
import { getIsLoggedIn } from "@/utils/auth";
import { setToken, removeToken, getToken } from "@/api/request.js";
/** localStorage:跨标签页记录当前前端的 bootId(与 vite define 的 __APP_BOOT_ID__ 对齐) */
const VITE_BOOT_STORAGE_KEY = "app_vite_boot_id";
/** 退出后强制回登录页(跨标签页/刷新生效) */
const FORCE_LOGIN_KEY = "force_login";
// ===== SSO/重定向相关(切线上接口时先注释保留) =====
// /** 强制走统一登录(仅前端控制):退出登录后置 1,直到 SSO 回跳带 ?token= 再清除 */
// const FORCE_SSO_LOGIN_KEY = "force_sso_login";
function getCurrentViteBootId() {
try {
// eslint-disable-next-line no-undef
return typeof __APP_BOOT_ID__ !== "undefined" ? String(__APP_BOOT_ID__) : "";
} catch {
return "";
}
}
/** 仅同步 bootId(SSO 回跳带 ?token= 时会提前 return,须单独写入,否则下次进入 /ZMOverView 会误判 boot 变更而清 token) */
function persistViteBootIdOnly() {
if (typeof window === "undefined") return;
const current = getCurrentViteBootId();
if (!current) return;
try {
window.localStorage.setItem(VITE_BOOT_STORAGE_KEY, current);
} catch {
// ignore
}
}
/**
* 每次重启 Vite/重新构建后 __APP_BOOT_ID__ 会变;仅当「曾记录过旧 bootId 且与当前不一致」时清空 token。
* 用 localStorage 而非 sessionStorage,避免新开标签页时误把有效 token 清掉导致请求不带 token、全员 401。
* 注意:须在处理完 URL ?token= 之后再执行,避免先清 token 再写入的竞态。
*/
function clearTokenIfNewDevBoot() {
if (typeof window === "undefined") return;
try {
const current = getCurrentViteBootId();
if (!current) return;
const saved = window.localStorage.getItem(VITE_BOOT_STORAGE_KEY);
if (saved && saved !== current) {
removeToken();
}
window.localStorage.setItem(VITE_BOOT_STORAGE_KEY, current);
} catch {
// ignore
}
}
// /** 统一认证网关:未登录时整页跳转此处,由后端再跳到登录页;登录成功后回跳到前端并带 ?token= */
// const SSO_GATEWAY_ORIGIN =
// import.meta.env.VITE_SSO_GATEWAY_ORIGIN || "http://172.19.21.228:28080";
//
// /** 网关注册的回跳地址常为 /callback;保存 token 后应进入业务首页,避免停在无路由的 /callback */
// const SSO_POST_LOGIN_PATH =
// import.meta.env.VITE_SSO_POST_LOGIN_PATH || "/ZMOverView";
/**
* 是否与「请求里 getToken()」一致:避免出现仅空白/脏数据时仍认为已登录 → 不走 SSO 且请求头无 token
*/
function hasStoredAuthToken() {
return Boolean(getToken());
}
const Home = () => import('@/views/home/index.vue')
const DataLibrary = () => import('@/views/dataLibrary/index.vue')
......@@ -33,6 +97,8 @@ const routes = [
{
path: "/",
name: "Home",
// 访问根路径时默认进入概览页(否则 Home 的 router-view 为空,看起来像白屏)
redirect: "/ZMOverView",
component: Home,
children: [
...fileRoutes,
......@@ -73,18 +139,88 @@ const router = createRouter({
routes
});
// 路由守卫 - 设置页面标题
// 路由守卫:SSO + 设置页面标题
// 1)首次/无本地 token:整页跳到网关 /api/v2 + 当前路径,走后端登录
// 2)登录成功回跳带 ?token=:先 setToken 并同步 bootId,再去掉 URL 中的 token(须先于 clearTokenIfNewDevBoot,避免误清刚写入的登录态)
// 3)已有本地 token:正常走前端路由
router.beforeEach((to, from, next) => {
// 登录态:同一次前端服务 BOOT_ID 内跨刷新/跨新标签有效;服务重启后自动失效
const isAuthed = getIsLoggedIn();
// ===== SSO/重定向逻辑(切线上接口时停用,保留注释) =====
// const queryToken = to.query && to.query.token != null && String(to.query.token).trim() !== ""
// ? String(to.query.token).trim()
// : "";
//
// if (queryToken) {
// setToken(queryToken);
// // 成功回跳拿到 token,说明统一登录链路已完成,清除强制标记
// try {
// if (typeof window !== "undefined") {
// window.sessionStorage.removeItem(FORCE_SSO_LOGIN_KEY);
// }
// } catch {
// // ignore
// }
// persistViteBootIdOnly();
// const restQuery = { ...to.query };
// delete restQuery.token;
// const isGatewayCallback =
// to.path === "/callback" || to.path.replace(/\/$/, "") === "/callback";
// const targetPath = isGatewayCallback ? SSO_POST_LOGIN_PATH : to.path;
// next({
// path: targetPath,
// query: restQuery,
// hash: to.hash,
// replace: true,
// });
// return;
// }
//
// // 若用户点了“退出登录”,即使本地还有残留 token/或别处写回,也强制先走统一登录链路
// try {
// if (typeof window !== "undefined" && window.sessionStorage.getItem(FORCE_SSO_LOGIN_KEY) === "1") {
// removeToken();
// const targetUrl = `${SSO_GATEWAY_ORIGIN}/api/v2${to.fullPath || "/"}`;
// window.location.replace(targetUrl);
// next(false);
// return;
// }
// } catch {
// // ignore
// }
// 外网/线上版本:不因重启清登录态;仅开发环境需要此逻辑
// clearTokenIfNewDevBoot();
if (import.meta.env.DEV) {
clearTokenIfNewDevBoot();
}
// 启用站内登录页:无 token 时统一进入 /login(不做外部重定向)
// 退出登录后:无论当前 getToken() 返回什么,都强制先去 /login,直到再次登录清除此标记
let forceLoginActive = false;
try {
if (typeof window !== "undefined" && window.localStorage.getItem(FORCE_LOGIN_KEY) === "1") {
forceLoginActive = true;
if (to.path !== "/login") {
next({ path: "/login", replace: true });
return;
}
}
} catch {
// ignore
}
const isLoginRoute = to.name === "Login" || /^\/login\/?$/.test(String(to.path || ""));
const isAuthed = hasStoredAuthToken();
// 已登录:不应停留在 /login(首页应为 /ZMOverView)
// 说明:force_login=1 时表示用户主动退出,仍允许进入 /login;否则一律跳回业务首页
if (isAuthed && to.path === "/login" && !forceLoginActive) {
const redirect = (to.query && to.query.redirect) ? String(to.query.redirect) : "";
next({ path: redirect || "/ZMOverView", replace: true });
return;
}
if (!isLoginRoute && !isAuthed) {
next({
path: "/login",
query: { redirect: to.fullPath }
});
if (!isAuthed && to.path !== "/login") {
// 防止误入 /callback 空白页
next({ path: "/login", replace: true });
return;
}
......
......@@ -15,6 +15,7 @@ const BillProgressForecast = () => import('@/views/bill/influence/ProgressForeca
const BillInfluenceScientificResearch = () => import('@/views/bill/influence/scientificResearch/index.vue')
const BillRelevantCircumstance = () => import('@/views/bill/relevantCircumstance/index.vue')
const BillVersionCompare = () => import('@/views/bill/versionCompare/index.vue')
const BillAllCommittee = () => import('@/views/bill/allCommittee/index.vue')
const billRoutes = [
......@@ -28,6 +29,19 @@ const billRoutes = [
isShowHeader: true
}
},
{
path: "/bill/allCommittee",
name: "BillAllCommittee",
component: BillAllCommittee,
meta: {
title: "法案委员会列表",
isShowHeader: true
}
},
{
path: "/billAllCommittee",
redirect: "/bill/allCommittee"
},
{
path: "/billLayout",
name: "BillLayoutContainer",
......
......@@ -511,6 +511,7 @@ export const useWrittingAsstaintStore = defineStore('writtingAsstaint', {
if (this.reportContent.includes('./out/img')) {
this.reportContent = this.reportContent.replaceAll('./out/img', outImgbaseUrl);
// console.log(reportContent)
}
};
......
<template>
<div class="view-box">
<div class="container-box">
<div class="hard-box">
<div class="hard-name text-title-0-show">美国国会委员会</div>
<div class="hard-num text-title-2-show">{{ committeeInfo.total }}</div>
<div style="width: 0; flex: auto"></div>
<div class="hard-input">
<el-input
v-model="committeeInfo.keyWord"
@keyup.enter="onAllCommittee()"
style="width: 100%; height: 100%"
:suffix-icon="Search"
placeholder="搜索委员会"
/>
</div>
</div>
<div class="date-box">
<div class="date-text">近期美国国会各委员会涉华提案数量汇总</div>
<TimeTabPane @time-click="handleDateChange" activeTime="近一年" />
</div>
<div class="committee-list" ref="refCommittee" v-loading="committeeInfo.loading">
<div class="committee-item" v-for="item in committeeInfo.list" :key="item.id" @click="handleToDataLibrary(item)">
<div class="item-left">
<img :src="iconCommit" alt="" />
</div>
<div class="item-right">
<div class="item-name one-line-ellipsis">{{ item.name }}</div>
<div class="item-chamber one-line-ellipsis">{{ item.chamber }}</div>
</div>
<div class="item-total">{{ item.count }}</div>
<el-icon color="var(--color-primary-100)">
<ArrowRightBold />
</el-icon>
</div>
</div>
<div class="pagination-box">
<el-pagination
@current-change="onAllCommittee"
:pageSize="committeeInfo.pageSize"
:current-page="committeeInfo.pageNum"
background
layout="prev, pager, next"
:total="committeeInfo.total"
/>
</div>
</div>
</div>
</template>
<script setup name="BillAllCommittee">
import { onMounted, reactive, ref } from "vue";
import { Search } from "@element-plus/icons-vue";
import { ArrowRightBold } from "@element-plus/icons-vue";
import router from "@/router";
import TimeTabPane from "@/components/base/TimeTabPane/index.vue";
import { getStatisticsBillCountByCommittee } from "@/api/bill/billHome";
import iconCommit from "../billHome/assets/icons/icon-commit.png";
const committeeInfo = reactive({
loading: false,
pageNum: 1,
pageSize: 8,
total: 0,
keyWord: "",
dateDesc: "近一年",
list: []
});
const getChamberLabel = orgType => {
if (orgType === "Senate") return "参议院";
if (orgType === "House") return "众议院";
return orgType || "";
};
const onAllCommittee = async num => {
committeeInfo.pageNum = num || 1;
committeeInfo.loading = true;
try {
const res = await getStatisticsBillCountByCommittee({
dateDesc: committeeInfo.dateDesc
});
if (res.code === 200 && Array.isArray(res.data)) {
const source = res.data
.map(item => ({
id: `${item.orgType || ""}-${item.orgName || ""}`,
name: item.orgName,
chamber: getChamberLabel(item.orgType),
count: Number(item.count || 0)
}))
.filter(item => !committeeInfo.keyWord || item.name?.includes(committeeInfo.keyWord))
.sort((a, b) => (b.count || 0) - (a.count || 0));
committeeInfo.total = source.length;
const start = (committeeInfo.pageNum - 1) * committeeInfo.pageSize;
committeeInfo.list = source.slice(start, start + committeeInfo.pageSize);
} else {
committeeInfo.total = 0;
committeeInfo.list = [];
}
} catch (error) {
committeeInfo.total = 0;
committeeInfo.list = [];
}
committeeInfo.loading = false;
};
const handleDateChange = event => {
committeeInfo.dateDesc = event?.time || "近一年";
onAllCommittee();
};
const handleToDataLibrary = item => {
const route = router.resolve({
path: "/dataLibrary/countryBill",
query: {
selectedOrg: item.name,
selectedCongress: item.chamber
}
});
window.open(route.href, "_blank");
};
const refCommittee = ref();
onMounted(() => {
let height = 2;
if (refCommittee.value) {
height = Math.floor(refCommittee.value?.clientHeight / 120);
}
committeeInfo.pageSize = height * 4;
onAllCommittee();
});
</script>
<style scoped lang="scss">
.view-box {
width: 100%;
height: 100%;
background: url("../billHome/assets/images/background.png"), linear-gradient(180deg, rgba(229, 241, 254, 1) 0%, rgba(246, 251, 255, 0) 30%);
background-size: 100% 100%;
display: flex;
justify-content: center;
.container-box {
width: 1600px;
padding: 50px 0 20px;
display: flex;
flex-direction: column;
.hard-box {
display: flex;
align-items: center;
width: 100%;
.hard-name {
color: var(--text-primary-90-color);
height: 62px;
line-height: 62px !important;
}
.hard-num {
height: 36px;
background-color: var(--color-primary-100);
color: var(--bg-white-100);
border-radius: 18px;
line-height: 36px !important;
padding: 0 16px;
margin-left: 16px;
}
.hard-input {
background-color: var(--el-fill-color-blank);
border-radius: var(--el-border-radius-base);
box-shadow: 0 0 0 1px var(--el-border-color) inset;
box-sizing: border-box;
margin-left: 20px;
width: 180px;
height: 32px;
}
}
.date-box {
margin-top: 6px;
display: flex;
align-items: center;
width: 100%;
.date-text {
width: 20px;
flex: auto;
font-size: 18px;
line-height: 18px;
font-family: Source Han Sans CN;
color: var(--text-primary-80-color);
}
}
.committee-list {
width: 100%;
height: 20px;
padding: 16px 0;
margin-top: 10px;
flex: auto;
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-auto-rows: 104px;
gap: 16px;
.committee-item {
padding: 0 16px;
display: flex;
background: rgba(255, 255, 255, 0.65);
border: 1px solid rgba(255, 255, 255, 1);
border-radius: 10px;
box-shadow: 0 0 20px 0 rgba(25, 69, 130, 0.1);
align-items: center;
cursor: pointer;
transition: transform 0.3s ease, box-shadow 0.3s ease;
&:hover {
transform: translateY(-3px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.item-left {
width: 48px;
height: 48px;
margin-right: 12px;
img {
width: 100%;
height: 100%;
}
}
.item-right {
flex: auto;
min-width: 0;
.item-name {
font-size: 20px;
font-weight: 700;
line-height: 22px;
color: var(--text-primary-80-color);
}
.item-chamber {
margin-top: 6px;
font-size: 14px;
color: var(--text-primary-50-color);
}
}
.item-total {
font-size: 20px;
font-weight: 700;
line-height: 20px;
color: var(--color-primary-100);
margin-right: 6px;
}
}
}
.pagination-box {
display: flex;
justify-content: center;
}
}
}
</style>
......@@ -93,7 +93,7 @@
<div class="item">
<div class="item-left">法案进展:</div>
<div class="item-right2">
<div class="tag" v-for="(val, idx) in getReversedProgress(item.progress)" :key="`${item.billId}-${val}-${idx}`" :style="{ zIndex: item.progress.length - idx }">{{ val }}</div>
<div class="tag" v-for="(val, idx) in item.progress" :key="`${item.billId}-${val}-${idx}`" :style="{ zIndex: item.progress.length - idx }">{{ val }}</div>
</div>
</div>
</div>
......@@ -458,8 +458,6 @@ const handleClickAvatar = async member => {
} catch (error) {}
};
const getReversedProgress = progress => (Array.isArray(progress) ? [...progress].reverse() : []);
const handleClickCommitteeBill = bill => {
if (!bill?.billId) return;
props.onClickToDetail({
......
......@@ -134,9 +134,10 @@ const handleAnalysisClick = analysisType => {
// 进展预测 -> 法案简介页(法案进展)
if (analysisType === "forsee") {
router.push({
const target = router.resolve({
path: `/billLayout/ProgressForecast/${billId}`,
});
window.open(target.href, "_blank");
return;
}
......
<template>
<div class="industry-wrap">
<div class="left">
<AnalysisBox title="涉及行业" :showAllBtn="false" width="100%" height="100%">
<div class="left-main">
<div class="left-center">
<el-select v-model="curHylyId" placeholder="请选择领域" class="left-center-select"
@change="handleIndustryChange">
<el-option v-for="item in industryList" :key="item.id" :label="item.name || item.hylymc"
:value="item.id" />
</el-select>
<el-input v-model="companySearchKeyword" placeholder="搜索实体" class="left-center-search"
:suffix-icon="Search" clearable />
</div>
<div class="left-list">
<div class="left-list-title">实体名称</div>
<div class="left-list-content">
<el-empty v-if="!curCompanyList?.length" style="padding: 60px 0;" description="暂无数据"
:image-size="100" />
<el-scrollbar v-else height="100%" always>
<div class="item-box">
<div class="item"
:class="{ itemActive: companyActiveIndex === ((currentPage - 1) * pageSize + idx) }"
@click="handleClickCompany(val, idx)" v-for="(val, idx) in curCompanyList"
:key="val.id">
<div class="item-icon">
<img :src="defaultIcon2" alt="" class="item-img" />
</div>
<div class="title"
:class="{ titleActive: companyActiveIndex === ((currentPage - 1) * pageSize + idx) }">
{{ val.name }}
</div>
<div class="icon">
<img v-if="val.status === 'up'" :src="upIcon" alt="" />
<img v-if="val.status === 'down'" :src="downIcon" alt="" />
<BillPageShell>
<div class="wrap">
<div class="left">
<AnalysisBox title="涉及行业" :showAllBtn="false" width="100%" height="100%">
<div class="left-main">
<div class="left-center">
<el-select
v-model="curHylyId"
placeholder="请选择领域"
class="left-center-select"
@change="handleIndustryChange"
>
<el-option
v-for="item in industryList"
:key="item.id"
:label="item.name || item.hylymc"
:value="item.id"
/>
</el-select>
<el-input
v-model="companySearchKeyword"
placeholder="搜索实体"
class="left-center-search"
:suffix-icon="Search"
clearable
/>
</div>
<div class="left-list">
<div class="left-list-title">实体名称</div>
<div class="left-list-content">
<el-empty
v-if="!curCompanyList?.length"
style="padding: 60px 0;"
description="暂无数据"
:image-size="100"
/>
<el-scrollbar v-else height="100%" always>
<div class="item-box">
<div
v-for="(val, idx) in curCompanyList"
:key="val.id"
class="item"
:class="{ itemActive: companyActiveIndex === ((currentPage - 1) * pageSize + idx) }"
@click="handleClickCompany(val, idx)"
>
<div class="item-icon">
<img :src="defaultIcon2" alt="" class="item-img" />
</div>
<div
class="title"
:class="{ titleActive: companyActiveIndex === ((currentPage - 1) * pageSize + idx) }"
>
{{ val.name }}
</div>
<div class="icon">
<img v-if="val.status === 'up'" :src="upIcon" alt="" />
<img v-if="val.status === 'down'" :src="downIcon" alt="" />
</div>
</div>
</div>
</div>
</el-scrollbar>
</el-scrollbar>
</div>
</div>
</div>
<div class="left-pagination">
<div class="left-pagination-left">{{ `共 ${filteredCompanyList.length} 项` }}</div>
<div class="left-pagination-right">
<el-pagination @current-change="handleCurrentChange" :pageSize="pageSize"
:current-page="currentPage" size="small" background layout="prev, pager, next"
:total="filteredCompanyList.length" />
<div class="left-pagination">
<div class="left-pagination-left">{{ `共 ${filteredCompanyList.length} 项` }}</div>
<div class="left-pagination-right">
<el-pagination
@current-change="handleCurrentChange"
:pageSize="pageSize"
:current-page="currentPage"
size="small"
background
layout="prev, pager, next"
:total="filteredCompanyList.length"
/>
</div>
</div>
</div>
</div>
</AnalysisBox>
</div>
<div class="box2">
<AnalysisBox :showAllBtn="false">
<template #custom-title>
<div class="custom-title">
<div class="title-left">
<div :class="['title-item', {'title-active': contentType==1}]" @click="headerContentType(1)">
<div class="title-icon">
<img :src="contentType==1 ? icon1620 : icon1621" alt="">
</AnalysisBox>
</div>
<div class="right">
<AnalysisBox :showAllBtn="false">
<template #custom-title>
<div class="custom-title">
<div class="title-left">
<div :class="['title-item', { 'title-active': contentType==1 }]" @click="headerContentType(1)">
<div class="title-icon">
<img :src="contentType==1 ? icon1620 : icon1621" alt="" />
</div>
<div>产业链</div>
</div>
<div>产业链</div>
</div>
<div :class="['title-item', {'title-active': contentType==2}]" @click="headerContentType(2)">
<div class="title-icon">
<img :src="contentType==2 ? icon422 : icon423" alt="">
<div :class="['title-item', { 'title-active': contentType==2 }]" @click="headerContentType(2)">
<div class="title-icon">
<img :src="contentType==2 ? icon422 : icon423" alt="" />
</div>
<div>实体关系</div>
</div>
<div>实体关系</div>
</div>
<div class="title-right" v-if="contentType==1">
<el-select v-model="industryChain.id" style="width: 100%" @change="onDecreeChainNodes">
<el-option v-for="item in industryChain.list" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</div>
</div>
<div class="title-right" v-if="contentType==1">
<el-select v-model="industryChain.id" style="width: 100%" @change="onDecreeChainNodes">
<el-option v-for="item in industryChain.list" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</template>
<div class="box2-main">
<AiTips :tips="tips" />
<div class="graph-box" v-if="contentType==1">
<ChartChain :listData="fishbone.list" :baseData="fishbone.base" />
</div>
<div class="graph-box" v-if="contentType==2 && graphInfo.nodes.length">
<GraphChart :nodes="graphInfo.nodes" :links="graphInfo.links" layoutType="force" />
</div>
</div>
</template>
<div class="box2-main">
<AiTips :tips="tips" />
<div class="graph-box" v-if="contentType==1">
<ChartChain :listData="fishbone.list" :baseData="fishbone.base" />
</div>
<div class="graph-box" v-if="contentType==2 && graphInfo.nodes.length">
<GraphChart :nodes="graphInfo.nodes" :links="graphInfo.links" layoutType="force" />
</div>
</div>
</AnalysisBox>
</AnalysisBox>
</div>
</div>
</div>
</BillPageShell>
</template>
<script setup>
......@@ -94,6 +124,8 @@ import { ref, onMounted, onBeforeUnmount, nextTick, computed, watch, reactive }
import { useRoute } from "vue-router";
import * as echarts from "echarts";
import BillPageShell from "@/views/bill/components/layout/BillPageShell.vue";
const route = useRoute();
import { getCompanyList, getHylyList, getCompanyDetail } from "@/api/influence";
import getLineChart from "./utils/lineChart";
......@@ -809,7 +841,7 @@ onMounted(async () => {
</script>
<style lang="scss" scoped>
.industry-wrap {
.wrap {
width: 100%;
height: 100%;
display: flex;
......@@ -1012,7 +1044,7 @@ onMounted(async () => {
.right {
margin-top: 16px;
margin-left: 16px;
width: 1247px;
width: 1104px;
height: 847px;
position: relative;
......@@ -1487,7 +1519,7 @@ onMounted(async () => {
}
.box-footer {
width: 1218px;
width: 100%;
height: 40px;
display: flex;
box-sizing: border-box;
......@@ -1510,13 +1542,17 @@ onMounted(async () => {
.box-footer-center {
margin-left: 13px;
margin-top: 8px;
width: 1119px;
flex: 1;
min-width: 0;
height: 24px;
color: var(--color-main-active);
font-family: Microsoft YaHei;
font-size: 14px;
font-weight: 400;
line-height: 24px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.box-footer-right {
......@@ -1539,10 +1575,10 @@ onMounted(async () => {
}
}
.box2 {
.right {
margin-top: 16px;
margin-left: 16px;
width: 1247px;
width: 1104px;
height: 847px;
position: relative;
......
......@@ -131,6 +131,7 @@
<IntelligentEntityText
:text="term?.fynr || ''"
:entities="termsHighlight ? getTermEntities(term, 'cn') : []"
:highlight="searchKeyword"
@on-entity-click="e => gotoSearchResults(e.text_span, '')"
/>
</div>
......@@ -141,6 +142,7 @@
<IntelligentEntityText
:text="term?.ywnr || ''"
:entities="termsHighlight ? getTermEntities(term, 'en') : []"
:highlight="searchKeyword"
@on-entity-click="e => gotoSearchResults(e.text_span, '')"
/>
</div>
......@@ -343,6 +345,27 @@ const chart1ColorList = ref([...MUTICHARTCOLORS]);
const chart2ColorList = ref([...MUTICHARTCOLORS]);
const chart2Data = ref([]);
const DOMAIN_MAX_DISPLAY_COUNT = 7;
const DOMAIN_OTHER_NAME = "其他";
const formatDomainChartData = (list = []) => {
if (!Array.isArray(list) || list.length <= DOMAIN_MAX_DISPLAY_COUNT) {
return list;
}
const topDomainList = list.slice(0, DOMAIN_MAX_DISPLAY_COUNT);
const otherCount = list.slice(DOMAIN_MAX_DISPLAY_COUNT).reduce((sum, item) => {
return sum + Number(item?.value || 0);
}, 0);
if (!otherCount) {
return topDomainList;
}
return [
...topDomainList,
{
name: DOMAIN_OTHER_NAME,
value: otherCount
}
];
};
const aiPaneVisible = ref({
domain: false,
......@@ -737,12 +760,13 @@ const handleGetBillHyly = async () => {
.map(name => {
return { label: name, value: name };
});
chart2Data.value = res.data.map(item => {
const domainChartData = res.data.map(item => {
return {
name: item.hylymc,
value: item.countTk
};
});
chart2Data.value = formatDomainChartData(domainChartData);
aiPaneFetched.value = { ...aiPaneFetched.value, domain: false };
let chart2 = getPieChart(chart2Data.value, chart2ColorList.value);
......
......@@ -212,7 +212,7 @@ const handleToRiskDetail = (item) => {
// 查看更多风险信号
const handleToMoreRiskSignal = () => {
const route = router.resolve("/vieRiskSignal");
const route = router.resolve("/viewRiskSignal");
window.open(route.href, "_blank");
};
......
......@@ -29,36 +29,22 @@
<div class="item-footer">分析报告</div>
</div>
</div> -->
<div class="date-box" v-if="keyOrganizationList.length">
<div class="date-icon">
<img :src="tipsTcon" alt="">
</div>
<div class="date-text">近期美国各联邦政府机构发布涉华政令数量汇总</div>
<TimeTabPane @time-click="onKeyOrganization" activeTime="近一年" />
</div>
<div class="home-main-header-item-box" v-if="keyOrganizationList.length">
<div class="organization-item" v-for="(item, index) in keyOrganizationList" :key="index">
<div class="item-left">
<img :src="item.imgUrl || DefaultIcon2" alt="" />
</div>
<div class="item-right one-line-ellipsis" @click="handleToInstitution(item)">{{ item.orgName }}</div>
<el-popover content="跳转至数据资源库" placement="top">
<template #reference>
<div class="item-total" @click="handleToDataLibrary(item)">{{ item.totalOrderNum }}</div>
</template>
</el-popover>
<el-icon color="var(--color-primary-100)">
<ArrowRightBold />
</el-icon>
<div class="item-dot" v-if="item.recentOrderNum">+{{ item.recentOrderNum }}</div>
</div>
<div class="organization-item" @click="onNavigateTo()">
<div class="item-more">查看全部机构 ({{ govInsList.length }}家)</div>
<el-icon color="var(--color-primary-100)">
<ArrowRightBold />
</el-icon>
</div>
</div>
<SummaryCardsPanel
descriptionText="近期美国各联邦政府机构发布涉华政令数量汇总"
:cards="keyOrganizationCards"
:totalCount="govInsList.length"
:tipIcon="tipsTcon"
:defaultAvatar="DefaultIcon2"
:loading="keyOrganizationLoading"
activeTime="近一年"
emptyText="暂无数据"
:moreCardMinCount="7"
moreText="查看全部机构"
@time-click="onKeyOrganization"
@name-click="handleToInstitution"
@count-click="handleToDataLibrary"
@more-click="onNavigateTo"
/>
</div>
<DivideHeader id="position1" class="divide" :titleText="'最新动态'"></DivideHeader>
<div class="home-main-center">
......@@ -421,11 +407,11 @@
</template>
<script setup>
import { onMounted, ref, watch, nextTick, reactive } from "vue";
import { onMounted, ref, watch, nextTick, reactive, computed } from "vue";
import router from "@/router";
import WordCloudChart from "@/components/base/WordCloundChart/index.vue"
import SimplePagination from "@/components/SimplePagination.vue";
import TimeTabPane from '@/components/base/TimeTabPane/index.vue';
import SummaryCardsPanel from "@/components/base/SummaryCardsPanel/index.vue";
import AiButton from '@/components/base/Ai/AiButton/index.vue';
import AiPane from '@/components/base/Ai/AiPane/index.vue';
import {
......@@ -1155,11 +1141,13 @@ const handleSwithCurDecree = name => {
// 关键机构
const keyOrganizationList = ref([]);
const keyOrganizationLoading = ref(false);
const onKeyOrganization = async (event) => {
let day = 365
if (event?.time === '近一周') day = 7
if (event?.time === '近一月') day = 30
if (event?.time === '近一年') day = 365
keyOrganizationLoading.value = true;
try {
const res = await getKeyOrganization({ day });
console.log("关键机构", res);
......@@ -1167,8 +1155,20 @@ const onKeyOrganization = async (event) => {
keyOrganizationList.value = res.data;
}
} catch (error) { }
keyOrganizationLoading.value = false;
}
const keyOrganizationCards = computed(() => {
return keyOrganizationList.value.map(item => ({
...item,
id: item.orgId,
name: item.orgName,
avatar: item.imgUrl,
count: item.totalOrderNum,
delta: item.recentOrderNum
}));
});
// 下钻至数据资源库
const handleToDataLibrary = (item) => {
const selectParam = {
......
......@@ -1141,7 +1141,7 @@ const radarOption = ref({
// 获取雷达图数据
const fetchRadarData = async checked => {
try {
const data = await getSanDomainCount(checked, "export");
const data = await getSanDomainCount(checked, allSanTypeIds.value.join(","));
if (data && Array.isArray(data) && data.length > 0) {
// 收集所有可能的领域名称
const allDomains = new Set();
......@@ -1785,15 +1785,6 @@ const handleMediaClick = item => {
</script>
<style lang="scss" scoped>
// * {
// margin: 0;
// padding: 0;
// }
:deep(.el-input__wrapper) {
// box-shadow: none;
}
.list-page {
padding-top: 0;
}
......@@ -3247,10 +3238,10 @@ const handleMediaClick = item => {
}
.text {
font-size: 20px;
font-size: 16px;
font-weight: 700;
font-family: "Microsoft YaHei";
line-height: 26px;
font-family: "Source Han Sans CN";
line-height: 24px;
color: rgb(5, 95, 194);
}
}
......
......@@ -2,14 +2,14 @@
<div class="list-page">
<div class="search-box">
<div style="display: flex; justify-content: center">
<el-select v-model="currentCCLVersion" style="width: 388px; height: 32px; margin-right: 14px">
<el-select v-model="currentCCLVersion" style="width: 360px; height: 32px; margin-right: 14px">
<el-option v-for="item in cclVersionList" :key="item.key" :label="item.value" :value="item.key" />
</el-select>
<el-input v-model="searchKeyword" class="search-input" placeholder="搜索物项或ECCN编码" :suffix-icon="Search" />
</div>
<div class="filters">
<el-checkbox v-model="viewNew" label="查看最近更新内容" />
<el-select placeholder="全部类别" v-model="currentCCLType" style="width: 388px; height: 32px; margin-right: 14px">
<el-select placeholder="全部类别" v-model="currentCCLType" style="width: 360px; height: 32px; margin-right: 14px">
<el-option v-for="item in CCLTypeList" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</div>
......@@ -381,14 +381,15 @@ onMounted(async () => {
align-items: center;
.search-input {
width: 388px;
width: 360px;
height: 32px;
:deep(.el-input__wrapper) {
padding: 0 11px;
border: 1.5px solid #dcdfe6;
// border: 1.5px solid #dcdfe6;
background-color: #fff;
border-radius: 4px;
box-shadow: 0 0 0 1px var(--el-input-border-color, var(--el-border-color)) inset;
}
:deep(.el-input__inner) {
......@@ -439,25 +440,26 @@ onMounted(async () => {
.left {
padding-bottom: 20px;
width: 388px;
width: 360px;
height: auto;
border-radius: 10px;
box-shadow: 0px 0px 20px 0px rgba(25, 69, 130, 0.1);
background-color: #fff;
.checkbox-group {
display: flex;
flex-wrap: wrap;
padding: 0 0 0 24px;
display: grid;
grid-template-columns: repeat(2, 160px);
gap: 8px 4px;
padding-left: 24px;
.el-checkbox {
width: 50%;
margin-right: 0;
margin-bottom: 4px;
// margin-bottom: 4px;
font-size: 16px;
font-weight: 400;
font-family: "Microsoft YaHei";
line-height: 24px;
height: 24px;
color: rgb(95, 101, 108);
}
......@@ -504,7 +506,7 @@ onMounted(async () => {
}
.right {
width: 1196px;
width: 1223px;
height: auto;
.title {
width: 100%;
......
......@@ -514,11 +514,6 @@ watch(customDateRange, () => {
</script>
<style scoped lang="scss">
* {
margin: 0;
padding: 0;
}
.list-page {
width: 1601px;
padding-bottom: 50px;
......@@ -532,7 +527,7 @@ watch(customDateRange, () => {
align-items: center;
.search-input {
width: 388px;
width: 360px;
height: 32px;
:deep(.el-input__wrapper) {
......@@ -583,7 +578,7 @@ watch(customDateRange, () => {
.left {
padding-bottom: 20px;
width: 388px;
width: 360px;
height: auto;
border-radius: 10px;
box-shadow: 0px 0px 20px 0px rgba(25, 69, 130, 0.1);
......@@ -654,7 +649,7 @@ watch(customDateRange, () => {
}
.right {
width: 1196px;
width: 1223px;
height: auto;
border-radius: 10px;
box-shadow: 0px 0px 20px 0px rgba(25, 69, 130, 0.1);
......
{
"id": 2140,
"name": "美国以伊朗石油非法贸易为由实施制裁,多家中国企业被列入制裁名单",
"domainList": [
"海洋"
],
"relyFileList": [
{
"id": null,
"name": "第13382号行政命令"
},
{
"id": null,
"name": "第14530号行政命令"
}
],
"relySanList": [
{
"sanTypeId": 2,
"id": 2145,
"title": "OFAC将38个实体及4名个人列入SDN清单,涉及中国关联主体",
"postDate": "2025-10-14"
}
],
"sanReasonList": [
"参与了从伊朗购买、收购、销售、运输或营销石油化工产品",
"参与了与采购、获取、销售、运输或销售伊朗石油及石油制品相关的重大交易"
],
"addObjectList": [
{
"key": "机构",
"value": 3
}
],
"delObjectList": [
{
"key": "人物",
"value": 1
}
],
"sanList": [
{
"entityId": "91310115MA1HBB8PXH",
"entityName": "SHANGHAI QIZHANG SHIP MANAGEMENT CO., LTD.",
"entityNameZh": "上海启章船舶管理有限公司",
"entityTypeId": 2,
"entityTypeName": "机构",
"domainNames": [
"海洋"
]
},
{
"entityId": "71180883",
"entityName": "ALL WIN SHIPPING MANAGEMENT LIMITED",
"entityNameZh": "誠安船舶管理有限公司",
"entityTypeId": 2,
"entityTypeName": "机构",
"domainNames": [
"海洋"
]
},
{
"entityId": "91370211MAEBUA7E2Q",
"entityName": "QINGDAO OCEAN KIMO SHIP MANAGEMENT CO LTD",
"entityNameZh": "青岛明洋凯茂船舶管理有限公司",
"entityTypeId": 2,
"entityTypeName": "机构",
"domainNames": [
"海洋"
]
}
]
}
\ No newline at end of file
......@@ -13,7 +13,7 @@
</div>
</div>
<div class="main">
<div v-if="activeIndex == 0">
<div class="sanctionTime" v-if="activeIndex == 0">
<div class="left">
<AnalysisBox title="选择制裁">
<div class="left-main">
......@@ -76,7 +76,7 @@
</template>
<div class="right-main">
<div class="right-main-content">
<div class="hintWrap">
<!-- <div class="hintWrap">
<div class="icon1"></div>
<div class="title">
2025年实体清单制裁范围扩大至芯片制造环节,为中国的芯片制造能力划定“技术天花板”,阻止其向更先进水平发展。制裁范围向上游设备和材料、下游先进封装以及关键工具(如EDA软件)延伸,意图瓦解中国构建自主可控产业链的努力。
......@@ -84,7 +84,7 @@
<div class="icon2Wrap">
<div class="icon2"></div>
</div>
</div>
</div> -->
<div class="right-main-content-main">
<div class="fishbone-wrapper">
<div class="fishbone-scroll-container" ref="scrollContainerRef">
......@@ -557,8 +557,6 @@ onUnmounted(() => {
</script>
<style scoped lang="scss">
.deep-mining {
width: 1601px;
margin: 0 auto;
......@@ -610,6 +608,11 @@ onUnmounted(() => {
padding-bottom: 50px;
display: flex;
justify-content: space-between;
.sanctionTime {
display: flex;
padding-top: 12px;
gap: 10px;
}
.left {
width: 480px;
......
......@@ -433,11 +433,6 @@ onMounted(() => {
</script>
<style scoped lang="scss">
* {
margin: 0;
padding: 0;
}
.introduction-page {
width: 1601px;
margin: 0 auto;
......
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论