提交 5c950255 authored 作者: coderBryanFu's avatar coderBryanFu

feat:新增组件:时间排序选择框

流水线 #568 已通过 于阶段
in 1 分 42 秒
// 引入 axios 请求
import axios from 'axios'
// 引入 element-plus 里面的消息提示
import { ElMessage } from 'element-plus'
import { getToken, setToken, removeToken, formatBearerAuthorization } from '@/api/request.js'
export { getToken, setToken, removeToken }
// 【新增】全局 AbortController,用于管理所有通过此 service 发出的请求
let globalAbortController = new AbortController()
// 【新增】暴露一个方法,供外部(如路由守卫)调用以取消所有正在进行的请求
export const cancelAllRequests = () => {
if (globalAbortController) {
globalAbortController.abort()
// 创建一个新的 Controller 以备下次使用
globalAbortController = new AbortController()
}
}
// 创建 axios 实例
const service = axios.create({
timeout: 30 * 1000 // 请求超时时间
})
// request 拦截器:与主 request 一致,就地改 config,避免 mergeConfig 破坏 url
service.interceptors.request.use(config => {
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.set('token', token, true)
} else {
config.headers.delete('token')
config.headers.delete('Token')
}
const reqUrl = String(config.url ?? '')
if (reqUrl.includes('aiAnalysis')) {
const aiApiKey = import.meta.env.VITE_AI_ANALYSIS_API_KEY
if (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)
}
}
// 【新增】将全局控制器的 signal 注入到当前请求中
// 注意:如果 config 中已经手动传入了 signal(例如组件内单独控制),则优先使用组件内的
if (!config.signal) {
config.signal = globalAbortController.signal
}
return config
}, error => {
console.log(error)
return Promise.reject(error)
})
// response 拦截器
service.interceptors.response.use(
response => {
const res = response?.data
if (!res) {
return Promise.reject(new Error('响应数据为空'))
}
// 根据需求:接口返回 code 不等于 200 的时候报错
if (res.code !== 200) {
ElMessage({
message: res.message || '请求失败',
type: 'error',
duration: 3 * 1000
})
return Promise.reject(res)
}
return res.data
},
error => {
console.log('err' + error)
const isCanceledError =
error?.code === 'ERR_CANCELED' ||
error?.name === 'CanceledError' ||
error?.name === 'AbortError' ||
(typeof error?.message === 'string' && /canceled/i.test(error.message))
if (isCanceledError) return Promise.reject(error)
// 处理 token 过期或无效的情况
const errUrl = String(error.config?.url || '')
const isAiAnalysisRequest = errUrl.includes('aiAnalysis')
if (
error.response &&
(error.response.status === 401 || error.response.status === 403) &&
!isAiAnalysisRequest
) {
ElMessage({
message: 'Token 已过期,请重新登录',
type: 'error',
duration: 3 * 1000
})
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: typeof error?.message === 'string' ? error.message : '请求失败',
type: 'error',
duration: 3 * 1000
})
}
return Promise.reject(error)
}
)
// 封装通用请求函数(支持 http(config) 和 http.get/post 等调用方式)
function http(config) {
return service(config)
}
// 为 http 函数添加快捷方法
http.get = function(url, params) {
return service({ url, method: 'get', params })
}
http.post = function(url, data) {
return service({ url, method: 'post', data })
}
http.put = function(url, data) {
return service({ url, method: 'put', data })
}
http.delete = function(url, params) {
return service({ url, method: 'delete', params })
}
export { http }
export default service
\ No newline at end of file
// 引入 axios 请求
// src/api/finance/service.js
import axios from 'axios'
// 引入 element-plus 里面的消息提示
import { ElMessage } from 'element-plus'
import { getToken, setToken, removeToken, formatBearerAuthorization } from '@/api/request.js'
import { getToken, setToken, removeToken } from '@/api/request.js'
export { getToken, setToken, removeToken }
// 定义全局控制器,以便在取消后重新赋值
let currentAbortController = new AbortController()
/**
* 获取当前有效的 AbortSignal
* 供 axios 拦截器和 fetch 请求共同使用
*/
export const getAbortSignal = () => {
return currentAbortController.signal
}
/**
* 取消所有正在进行的请求
* 路由守卫中调用此方法
*/
export const cancelAllRequests = () => {
// 1. 终止当前控制器的所有请求
currentAbortController.abort()
// 2. 创建一个新的控制器,供后续新请求使用
currentAbortController = new AbortController()
}
// 创建 axios 实例
const service = axios.create({
timeout: 30 * 1000 // 请求超时时间 30s
})
// request 拦截器:与主 request 一致,就地改 config,避免 mergeConfig 破坏 url
// request 拦截器
service.interceptors.request.use(config => {
const raw = getToken()
const token = raw ? String(raw).trim() : ""
// 处理 Headers
if (!config.headers) {
config.headers = new axios.AxiosHeaders()
} else if (!(config.headers instanceof axios.AxiosHeaders)) {
config.headers = axios.AxiosHeaders.from(config.headers)
}
// 设置 Token
if (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 分析接口的特殊 Header
const reqUrl = String(config.url ?? '')
if (reqUrl.includes('aiAnalysis')) {
const aiApiKey = import.meta.env.VITE_AI_ANALYSIS_API_KEY
if (aiApiKey) {
// 确保 headers 存在
if (!config.headers) {
config.headers = new axios.AxiosHeaders()
} else if (!(config.headers instanceof axios.AxiosHeaders)) {
......@@ -42,6 +67,13 @@ service.interceptors.request.use(config => {
config.headers.set('X-API-Key', aiApiKey)
}
}
// 将全局控制器的 signal 注入到 axios 请求中
// 如果用户手动传入了 signal,则优先使用用户的(虽然少见)
if (!config.signal) {
config.signal = getAbortSignal()
}
return config
}, error => {
console.log(error)
......@@ -55,7 +87,6 @@ service.interceptors.response.use(
if (!res) {
return Promise.reject(new Error('响应数据为空'))
}
// 根据需求:接口返回 code 不等于 200 的时候报错
if (res.code !== 200) {
ElMessage({
message: res.message || '请求失败',
......@@ -67,19 +98,25 @@ service.interceptors.response.use(
return res.data
},
error => {
console.log('err' + error)
// 精准识别取消错误,避免弹窗骚扰用户
const isCanceledError =
axios.isCancel(error) ||
error?.code === 'ERR_CANCELED' ||
error?.name === 'CanceledError' ||
error?.name === 'AbortError' ||
(typeof error?.message === 'string' && /canceled/i.test(error.message))
if (isCanceledError) return Promise.reject(error)
if (isCanceledError) {
// 静默处理取消错误,不弹窗,不打印.error
return Promise.reject(error)
}
console.log('err' + error)
// 处理 token 过期或无效的情况
// 处理 Token 过期
const errUrl = String(error.config?.url || '')
const isAiAnalysisRequest = errUrl.includes('aiAnalysis')
if (
error.response &&
(error.response.status === 401 || error.response.status === 403) &&
......@@ -94,14 +131,11 @@ service.interceptors.response.use(
const hadToken =
h &&
(typeof h.get === 'function'
? Boolean(
h.get('token') || h.get('Token')
)
: Boolean(
h.token || h.Token
))
? Boolean(h.get('token') || h.get('Token'))
: Boolean(h.token || h.Token))
if (hadToken) removeToken()
} else {
// 只有非取消、非 Token 过期的错误才弹出通用提示
ElMessage({
message: typeof error?.message === 'string' ? error.message : '请求失败',
type: 'error',
......@@ -112,12 +146,11 @@ service.interceptors.response.use(
}
)
// 封装通用请求函数(支持 http(config) 和 http.get/post 等调用方式)
// 封装通用请求函数
function http(config) {
return service(config)
}
// 为 http 函数添加快捷方法
http.get = function(url, params) {
return service({ url, method: 'get', params })
}
......
......@@ -25,7 +25,7 @@
<el-icon color="var(--color-primary-100)">
<ArrowRightBold />
</el-icon>
<div class="item-dot" v-if="item.delta">+{{ item.delta }}</div>
<div class="item-dot" v-if="item.delta">{{ dotPrefix }}{{ item.delta }}</div>
</div>
<div v-if="shouldShowMoreCard" class="summary-item" @click="emit('more-click')">
......@@ -100,6 +100,10 @@ const props = defineProps({
loading: {
type: Boolean,
default: false
},
dotPrefix: {
type: String,
default: "+"
}
});
......
......@@ -2,6 +2,8 @@ import { createRouter, createWebHistory } from "vue-router";
import { setToken, removeToken, getToken } from "@/api/request.js";
import { AUTH_LOGOUT_CHANNEL } from "@/utils/authCrossTabLogout.js";
import { cancelAllRequests } from "@/api/finance/service.js"
/** localStorage:跨标签页记录当前前端的 bootId(与 vite define 的 __APP_BOOT_ID__ 对齐) */
const VITE_BOOT_STORAGE_KEY = "app_vite_boot_id";
/** 退出后强制回登录页(跨标签页/刷新生效) */
......@@ -145,51 +147,9 @@ const router = createRouter({
// 2)登录成功回跳带 ?token=:先 setToken 并同步 bootId,再去掉 URL 中的 token(须先于 clearTokenIfNewDevBoot,避免误清刚写入的登录态)
// 3)已有本地 token:正常走前端路由
router.beforeEach((to, from, next) => {
// ===== 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();
// 【新增】在每次路由跳转开始前,取消上一个页面所有未完成的请求
// 这能防止旧页面的数据回来覆盖新页面,也能减少服务器压力
cancelAllRequests();
if (import.meta.env.DEV) {
clearTokenIfNewDevBoot();
}
......
......@@ -35,7 +35,7 @@ const billRoutes = [
component: BillAllCommittee,
meta: {
title: "法案委员会列表",
isShowHeader: true
isShowHeader: false
}
},
{
......
......@@ -14,6 +14,12 @@
placeholder="搜索委员会"
/>
</div>
<div class="hard-select">
<el-select v-model="committeeInfo.metricType" @change="onAllCommittee()" placeholder="统计口径" style="width: 160px; margin-left: 8px">
<el-option label="政令数据总量" :value="1" />
<el-option label="政令新增数量" :value="2" />
</el-select>
</div>
</div>
<div class="date-box">
......@@ -30,7 +36,7 @@
<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>
<div class="item-total">{{ getDisplayCount(item) }}</div>
<el-icon color="var(--color-primary-100)">
<ArrowRightBold />
</el-icon>
......@@ -48,6 +54,12 @@
/>
</div>
</div>
<div class="back-bnt" @click="handleBack">
<el-icon>
<Back />
</el-icon>
<div class="back-text">返回</div>
</div>
</div>
</template>
......@@ -55,6 +67,7 @@
import { onMounted, reactive, ref } from "vue";
import { Search } from "@element-plus/icons-vue";
import { ArrowRightBold } from "@element-plus/icons-vue";
import { Back } from "@element-plus/icons-vue";
import router from "@/router";
import TimeTabPane from "@/components/base/TimeTabPane/index.vue";
import { getStatisticsBillCountByCommittee } from "@/api/bill/billHome";
......@@ -66,6 +79,7 @@ const committeeInfo = reactive({
pageSize: 8,
total: 0,
keyWord: "",
metricType: 1,
dateDesc: "近一年",
list: []
});
......@@ -89,10 +103,11 @@ const onAllCommittee = async num => {
id: `${item.orgType || ""}-${item.orgName || ""}`,
name: item.orgName,
chamber: getChamberLabel(item.orgType),
count: Number(item.count || 0)
totalCount: Number(item.count || 0),
recentCount: Number(item.countRecent || item.totalRecent || item.recentCount || item.recent || item.newCount || 0)
}))
.filter(item => !committeeInfo.keyWord || item.name?.includes(committeeInfo.keyWord))
.sort((a, b) => (b.count || 0) - (a.count || 0));
.sort((a, b) => getSortValue(b) - getSortValue(a));
committeeInfo.total = source.length;
const start = (committeeInfo.pageNum - 1) * committeeInfo.pageSize;
......@@ -108,20 +123,36 @@ const onAllCommittee = async num => {
committeeInfo.loading = false;
};
const getSortValue = item => {
if (committeeInfo.metricType === 2) return Number(item?.recentCount || 0);
return Number(item?.totalCount || 0);
};
const getDisplayCount = item => {
return getSortValue(item);
};
const handleDateChange = event => {
committeeInfo.dateDesc = event?.time || "近一年";
onAllCommittee();
};
const handleToDataLibrary = item => {
const route = router.resolve({
router.push({
path: "/dataLibrary/countryBill",
query: {
selectedOrg: item.name,
selectedCongress: item.chamber
}
});
window.open(route.href, "_blank");
};
const handleBack = () => {
if (window.history.length > 1) {
router.back();
return;
}
router.push("/billHome");
};
const refCommittee = ref();
......@@ -143,6 +174,28 @@ onMounted(() => {
background-size: 100% 100%;
display: flex;
justify-content: center;
position: relative;
.back-bnt {
position: absolute;
top: 16px;
left: 30px;
width: 86px;
height: 38px;
background-color: white;
border-radius: 19px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary-65-color);
font-family: Source Han Sans CN;
font-size: 16px;
cursor: pointer;
}
.back-text {
margin-left: 6px;
}
.container-box {
width: 1600px;
......@@ -180,6 +233,11 @@ onMounted(() => {
width: 180px;
height: 32px;
}
.hard-select {
height: 42px;
padding: 5px 0;
}
}
.date-box {
......
......@@ -56,7 +56,31 @@
<AreaTag v-for="(item, index) in bill.hylyList" :key="index" :tagName="item.industryName">
</AreaTag>
</div>
<div class="box1-main-divider"></div>
<div class="box1-main-left-info1">
<div class="info1-box">
<div class="icon"></div>
<div class="info1-box-left">{{ "提出部门:" }}</div>
<div class="info1-box-right info1-box-right--committee">
<template v-if="getLimitedCommitteeList(bill.committeeList).length">
<div
v-for="committee in getLimitedCommitteeList(bill.committeeList)"
:key="committee.committeeId || committee.committeeName"
class="committee-item"
>
<img
class="committee-logo"
:src="committee.logoUrl || iconCommit"
:alt="committee.committeeName || '提案部门'"
/>
<div class="committee-name">
{{ committee.committeeName || "--" }}
</div>
</div>
</template>
<div v-else>--</div>
</div>
</div>
<div class="info1-box">
<div class="icon"></div>
<div class="info1-box-left">{{ "提案人:" }}</div>
......@@ -70,9 +94,11 @@
</div>
</div>
</div>
<div class="box1-main-divider box1-main-divider--before-list"></div>
<div class="box1-main-left-info2">
<div class="info2-item" v-for="(item, index) in bill.dyqkList" :key="index">
<div class="time-line" v-if="index !== bill.dyqkList.length - 1"></div>
<div class="info2-item" v-for="(item, index) in getLimitedDyqkList(bill.dyqkList)"
:key="index">
<div class="time-line" v-if="!isLastDyqkItem(bill.dyqkList, index)"></div>
<div class="item-icon">
<img src="./assets/images/info2-icon.png" alt="" />
</div>
......@@ -147,7 +173,7 @@
</div>
</div>
</OverviewCard>
<OverviewCard class="overview-card--single box6" title="领域分布情况" :icon="box6HeaderIcon">
<OverviewCard class="overview-card--single box6" title="领域分布情况" :icon="box7HeaderIcon">
<template #right>
<el-select v-model="box9selectetedTime" placeholder="选择时间" style="width: 90px">
<el-option v-for="item in box9YearList" :key="item.value" :label="item.label" :value="item.value" />
......@@ -186,7 +212,7 @@
<div v-else id="box7Chart" class="overview-chart"></div>
</div>
<div class="overview-tip-row">
<TipTab class="overview-tip" :text="'提出涉华科技法案委员会分布情况,数据来源:美国国会官网'" />
<TipTab class="overview-tip" :text="'涉华科技法案提案委员会分布情况,数据来源:美国国会官网'" />
<AiButton class="overview-tip-action" @mouseenter="handleShowAiPane('box7')" />
</div>
<div v-if="aiPaneVisible.box7" class="overview-ai-pane" @mouseleave="handleHideAiPane('box7')">
......@@ -194,7 +220,7 @@
</div>
</div>
</OverviewCard>
<OverviewCard class="overview-card--single box8" title="进展分布情况" :icon="box7HeaderIcon">
<OverviewCard class="overview-card--single box8" title="进展分布情况" :icon="box8HeaderIcon">
<template #right>
<el-select v-model="box8selectetedTime" placeholder="选择时间" style="width: 90px">
<el-option v-for="item in box8YearList" :key="item.value" :label="item.label" :value="item.value" />
......@@ -217,7 +243,7 @@
</div>
</div>
</OverviewCard>
<OverviewCard class="overview-card--single box9" title="关键条款词云" :icon="box7HeaderIcon">
<OverviewCard class="overview-card--single box9" title="关键条款词云" :icon="box6HeaderIcon">
<div class="overview-card-body box9-main">
<div class="overview-chart-wrap" v-loading="chartLoading.box9">
<el-empty v-if="!wordCloudHasData" description="暂无数据" :image-size="100" />
......@@ -290,6 +316,7 @@ import getDoublePieChart from "./utils/doublePieChart";
import box5HeaderIcon from "./assets/images/box5-header-icon.png";
import box6HeaderIcon from "./assets/images/box6-header-icon.png";
import box7HeaderIcon from "./assets/images/box7-header-icon.png";
import box8HeaderIcon from "./assets/images/box8-header-icon.png"
import iconCommit from "./assets/icons/icon-commit.png";
import iconILetter from "./assets/icons/icon-iLetter.png";
......@@ -386,7 +413,9 @@ const committeeCards = computed(() => {
orgId: item.orgId,
name: item.name,
subText: item.chamber,
count: item.count
count: item.total,
delta: item.count,
avatar: item.logoUrl
}));
});
......@@ -403,9 +432,11 @@ const handleGetCommitteeBillCount = async () => {
orgId: item.orgId,
name: item.orgName,
chamber: getChamberLabel(item.orgType),
count: Number(item.count || 0)
total: Number(item.total || 0),
count: Number(item.count || 0),
logoUrl: item.logoUrl || ""
}))
.sort((a, b) => (b.count || 0) - (a.count || 0));
.sort((a, b) => (b.total || 0) - (a.total || 0));
committeeTotalCount.value = mappedList.length;
committeeCardList.value = mappedList.slice(0, 3);
} else {
......@@ -431,6 +462,7 @@ const handleToCommitteeMore = () => {
const hotBillList = ref([]); // 热门法案列表
const carouselRef = ref(null);
const MAX_DYQK_DISPLAY_COUNT = 3;
const handleCarouselChange = index => {
if (hotBillList.value && hotBillList.value.length > 0) {
......@@ -438,6 +470,20 @@ const handleCarouselChange = index => {
}
};
const getLimitedDyqkList = dyqkList => {
if (!Array.isArray(dyqkList)) return [];
return dyqkList.slice(0, MAX_DYQK_DISPLAY_COUNT);
};
const getLimitedCommitteeList = committeeList => {
if (!Array.isArray(committeeList)) return [];
return committeeList.slice(0, 2);
};
const isLastDyqkItem = (dyqkList, index) => {
return index === getLimitedDyqkList(dyqkList).length - 1;
};
// 切换热门法案
const handleSwithCurBill = name => {
if (name === "left") {
......@@ -500,25 +546,17 @@ const handleToMoreNews = () => {
// 风险信号
const warningList = ref([]);
const box7selectetedTime = ref("2025");
const box7YearList = ref([
{
label: "2025",
value: "2025"
},
{
label: "2024",
value: "2024"
},
{
label: "2023",
value: "2023"
},
{
label: "2022",
value: "2022"
}
]);
const currentYear = new Date().getFullYear();
const recentFiveYearOptions = Array.from({ length: 5 }, (_, index) => {
const year = String(currentYear - index);
return {
label: year,
value: year
};
});
const box7selectetedTime = ref(String(currentYear));
const box7YearList = ref(recentFiveYearOptions);
const aiPaneVisible = ref({
box5: false,
......@@ -684,25 +722,8 @@ const handleHideAiPane = key => {
};
};
const box8selectetedTime = ref("2025");
const box8YearList = ref([
{
label: "2025",
value: "2025"
},
{
label: "2024",
value: "2024"
},
{
label: "2023",
value: "2023"
},
{
label: "2022",
value: "2022"
}
]);
const box8selectetedTime = ref(String(currentYear));
const box8YearList = ref(recentFiveYearOptions);
// 涉华法案数量使用的领域分类列表
const categoryList = ref([]);
......@@ -757,7 +778,7 @@ const handleGetNews = async () => {
newsList.value = res.data.map(item => {
return {
...item,
from: `${item.newsOrg} · ${item.newsDate ? item.newsDate.slice(5) : ""}`
from: `${item.newsDate ? item.newsDate : ""} · ${item.newsOrg || ""}`
};
});
} else {
......@@ -947,50 +968,70 @@ const box7HasData = ref(true);
const box7AiData = ref({ inner: [], outer: [] });
const handleBox7Data = async () => {
chartLoading.value = { ...chartLoading.value, box7: true };
const selectParam = {
moduleType: '国会法案',
key: 3,
selectedDate: box7selectetedTime.value ? JSON.stringify([box7selectetedTime.value + '-01-01', box7selectetedTime.value + '-12-31']) : '',
isInvolveCn: true
}
try {
const res = await getBillPostOrg({ year: box7selectetedTime.value });
console.log("法案提出部门", res);
const orgBillNumList = res?.data?.orgBillNumList || [];
const orgBillNumMap = res?.data?.orgBillNumMap || {};
if (res.code === 200 && Array.isArray(orgBillNumList) && orgBillNumList.length > 0) {
box7HasData.value = true;
// 必须等待DOM更新,因为v-if切换可能导致元素刚被创建
await nextTick();
let innerData = [];
let outerData = [];
if (Array.isArray(res?.data)) {
innerData = res.data
.map(item => ({
name: item?.orgName || item?.orgType || "",
value: Number(item?.count || 0)
}))
.filter(item => item.name && item.value > 0);
outerData = res.data.flatMap(item => {
const typeName = item?.orgName || item?.orgType || "";
const list = Array.isArray(item?.orgBillNumList) ? item.orgBillNumList : [];
return list
.map(child => ({
name: child?.orgName || "",
value: Number(child?.count || 0),
percent: typeof child?.percent === "number" ? child.percent : Number(child?.percent || 0),
type: child?.orgType || typeName
}))
.filter(child => child.name && child.value > 0);
});
} else {
const orgBillNumList = res?.data?.orgBillNumList || [];
const orgBillNumMap = res?.data?.orgBillNumMap || {};
const data1 = [];
const houseTotal = Number(orgBillNumMap?.House || 0);
const senateTotal = Number(orgBillNumMap?.Senate || 0);
if (houseTotal > 0) data1.push({ name: "众议院", value: houseTotal });
if (senateTotal > 0) data1.push({ name: "参议院", value: senateTotal });
const getOrgTypeLabel = orgType => (orgType === "Senate" ? "参议院" : "众议院");
const typeOrderMap = { 众议院: 0, 参议院: 1 };
const data2 = orgBillNumList
innerData = data1;
outerData = orgBillNumList
.map(item => ({
name: item.orgName,
value: Number(item.count || 0),
percent: typeof item.percent === "number" ? item.percent : Number(item.percent || 0),
type: getOrgTypeLabel(item.orgType)
type: item.orgType === "Senate" ? "参议院" : "众议院"
}))
// 关键:外环顺序必须按内环(众→参)分组,否则扇区角度会交错导致“不对应”
.sort((a, b) => {
const t1 = typeOrderMap[a.type] ?? 99;
const t2 = typeOrderMap[b.type] ?? 99;
if (t1 !== t2) return t1 - t2;
return (b.value ?? 0) - (a.value ?? 0);
});
.filter(item => item.name && item.value > 0);
}
if (res.code === 200 && innerData.length > 0 && outerData.length > 0) {
box7HasData.value = true;
// 必须等待DOM更新,因为v-if切换可能导致元素刚被创建
await nextTick();
const selectParam = {
moduleType: '国会法案',
key: 3,
selectedDate: box7selectetedTime.value ? JSON.stringify([box7selectetedTime.value + '-01-01', box7selectetedTime.value + '-12-31']) : '',
isInvolveCn: true
}
const typeOrderMap = new Map(innerData.map((item, index) => [item.name, index]));
const data2 = outerData.sort((a, b) => {
const t1 = typeOrderMap.get(a.type) ?? 99;
const t2 = typeOrderMap.get(b.type) ?? 99;
if (t1 !== t2) return t1 - t2;
return (b.value ?? 0) - (a.value ?? 0);
});
const box7Chart = getDoublePieChart(data1, data2);
const box7Chart = getDoublePieChart(innerData, data2);
setChart(box7Chart, "box7Chart", true, selectParam);
box7AiData.value = { inner: data1, outer: data2 };
box7AiData.value = { inner: innerData, outer: data2 };
} else {
// 接口异常(如500)时,清空图表数据以避免报错或显示错误信息
box7HasData.value = false;
......@@ -1057,7 +1098,7 @@ const handleBox6 = async () => {
// 涉华领域分布
const box9ChartData = ref([]);
const box9selectetedTime = ref("2025");
const box9selectetedTime = ref(String(currentYear));
// 立法状态下拉:提出法案、众议院通过、参议院通过、解决分歧、呈交总统、完成立法
// v-model 存储的是接口需要的 status 值(直接作为接口参数)
const box9LegislativeStatus = ref("提出法案");
......@@ -1069,28 +1110,7 @@ const box9LegislativeStatusList = ref([
{ label: "呈交总统", value: "呈交总统" },
{ label: "完成立法", value: "完成立法" }
]);
const box9YearList = ref([
{
label: "2026",
value: "2026"
},
{
label: "2025",
value: "2025"
},
{
label: "2024",
value: "2024"
},
{
label: "2023",
value: "2023"
},
{
label: "2022",
value: "2022"
}
]);
const box9YearList = ref(recentFiveYearOptions);
const box9HasData = ref(true);
let box9ChartInstance = null;
const BOX9_MAX_DOMAIN_COUNT = 7;
......@@ -1149,7 +1169,7 @@ const handleBox9Data = async () => {
};
}),
null,
{ showCount: false }
{ showCount: true, countUnit: "项" }
);
// 记录埋点时,将当前选中的立法状态映射为序号(0-4)
const selectedIndex = box9LegislativeStatusList.value.findIndex(
......@@ -2076,12 +2096,22 @@ onUnmounted(() => {
gap: 8px;
}
.box1-main-divider {
margin-top: 18px;
margin-bottom: 18px;
width: 468px;
height: 1px;
background: var(--bg-black-5);
}
.box1-main-left-info1 {
margin-top: 25px;
margin-top: 0;
margin-left: 4px;
.info1-box {
display: flex;
min-height: 30px;
align-items: flex-start;
.icon {
margin-top: 15px;
......@@ -2104,18 +2134,60 @@ onUnmounted(() => {
.info1-box-right {
margin-left: 40px;
height: 30px;
min-height: 30px;
color: var(--text-primary-65-color);
font-family: Microsoft YaHei;
font-size: var(--font-size-base);
font-weight: 400;
line-height: 30px;
}
.info1-box-right--committee {
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: 4px;
height: auto;
min-height: 30px;
line-height: 22px;
padding-top: 4px;
.committee-item {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.committee-logo {
width: 18px;
height: 18px;
min-width: 18px;
border-radius: 50%;
object-fit: cover;
background: var(--bg-black-5);
}
.committee-name {
color: var(--text-primary-65-color);
font-family: Microsoft YaHei;
font-size: var(--font-size-base);
font-weight: 400;
line-height: 22px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
.box1-main-divider--before-list {
margin-top: 18px;
}
.box1-main-left-info2 {
margin-top: 21px;
margin-top: 0;
height: 200px;
width: 440px;
position: relative;
......@@ -2216,7 +2288,7 @@ onUnmounted(() => {
left: 0;
bottom: 0;
box-sizing: border-box;
padding: 9px 10px 12px 10px;
padding: 9px 20px 12px 20px;
.inner-box-header {
height: 30px;
......
......@@ -69,7 +69,7 @@ const getDoublePieChart = (data1, data2) => {
const name = truncateLabel(params?.name, 6)
const value = params?.value ?? 0
const percent = typeof params?.percent === 'number' ? params.percent : 0
return `{name|${name}}\n{time|${value} ${percent}%}`
return `{name|${name}}\n{time|${value} ${percent}%}`
},
minMargin: 5,
edgeDistance: 10,
......
......@@ -10,6 +10,7 @@ const truncateLabel = (value, maxLen = 6) => {
const getPieChart = (data, colorList, options = {}) => {
const showCount = options.showCount !== false
const countUnit = options.countUnit || '条'
const chartColors = Array.isArray(colorList) && colorList.length ? colorList : MUTICHARTCOLORS
let option = {
color: chartColors,
......@@ -38,7 +39,7 @@ const getPieChart = (data, colorList, options = {}) => {
const name = truncateLabel(params?.name, 6)
const value = params?.value ?? 0
const percent = typeof params?.percent === 'number' ? params.percent : 0
const labelText = showCount ? `${value} ${percent}%` : `${percent}%`
const labelText = showCount ? `${value}${countUnit} ${percent}%` : `${percent}%`
return `{name|${name}}\n{time|${labelText}}`
},
minMargin: 5,
......
......@@ -34,14 +34,19 @@
<img src="./assets/images/box1-right.png" alt="" />
</div>
</div>
<el-carousel ref="carouselRef" height="370px" :autoplay="true" :interval="3000" arrow="never"
indicator-position="none" @change="handleCarouselChange">
<el-carousel
ref="carouselRef"
height="370px"
:autoplay="false"
:interval="3000"
arrow="never"
indicator-position="none"
@change="handleCarouselChange"
>
<el-carousel-item v-for="(item, index) in entitiesDataInfoList" :key="item.id + index">
<div>
<div class="box1-top">
<div class="box1-top-title">
{{ item.postDate }}——BIS《实体清单增列与修订条目》
</div>
<div class="box1-top-title">{{ item.postDate }}——{{ item.name }}</div>
<div class="box1-top-content">
<div class="box1-top-content-item">
<span class="box1-top-content-item-title">· 发布机构:</span>
......@@ -53,14 +58,12 @@
</div>
<div class="box1-top-content-item">
<span class="box1-top-content-item-title">· 涉及领域:</span>
<!-- <div
class="box1-top-content-item-tags"
<AreaTag
v-for="(domainItem, index) in item.domains"
:key="index"
>
<el-tag :type="getTagType(domainItem)">{{ domainItem }}</el-tag>
</div> -->
<AreaTag v-for="(domainItem, index) in item.domains" :key="index" :tagName="domainItem" />
:tagName="domainItem"
/>
</div>
</div>
</div>
......@@ -110,7 +113,7 @@
}}</span>
</div>
<div class="box1-absolute-num">
{{ item.cnEntityCount }}{{ item.sanTypeId == allSanTypeIds[0] ? "家" : "" }}
{{ item.cnEntityCount }}{{ item.sanTypeId == allSanTypeIds[0] ? "家" : "" }}
</div>
</div>
</div>
......@@ -1819,11 +1822,12 @@ const handleMediaClick = item => {
flex-direction: column;
gap: 20px;
position: relative;
width: 1036px;
.box1-left-arrow {
position: absolute;
z-index: 9999;
left: -20px;
left: -24px;
top: 135px;
width: 24px !important;
height: 48px;
......@@ -1847,7 +1851,7 @@ const handleMediaClick = item => {
.box1-right-arrow {
position: absolute;
z-index: 9999;
right: -20px;
right: 0px;
top: 135px;
width: 24px;
height: 48px;
......@@ -1912,6 +1916,10 @@ const handleMediaClick = item => {
color: $base-color;
margin-top: 10px;
margin-bottom: 15px;
max-width: 80%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&-content {
......@@ -1938,7 +1946,7 @@ const handleMediaClick = item => {
height: 172px;
padding-top: 16px;
box-sizing: border-box;
padding-right: 24px;
&-title {
font-size: 16px;
font-weight: 700;
......
......@@ -60,9 +60,10 @@
<div>
<div class="box1-top">
<div class="box1-top-title">
{{ item.postDate }}——{{
<!-- {{ item.postDate }}——{{
item.sanTypeId == allSanTypeIds[0] ? "OFAC" : "DoD"
}}《实体清单增列与修订条目》
}}《实体清单增列与修订条目》 -->
{{ item.postDate }}——{{ item.name }}
</div>
<div class="box1-top-content">
<div class="box1-top-content-item">
......@@ -1073,7 +1074,7 @@ const handleToEntityList = item => {
console.log("这是什么数据1 =>", item);
let id = item?.id;
let sanTypeId = item?.sanTypeId || 1;
let date = entitiesDataInfoList.value[currentCarouselIndex.value].postDate
let date = entitiesDataInfoList.value[currentCarouselIndex.value].postDate;
if (!id) {
const currentItem = entitiesDataInfoList.value[currentCarouselIndex.value];
id = currentItem?.id;
......@@ -1826,14 +1827,20 @@ const handleToDataLibrary = item => {
onMounted(async () => {
console.log("finance 页面 mounted");
try {
// 获取趋势图数据
fetchTrendData();
fetchRiskSignals("0109");
// 获取社交媒体信息
fetchSocialMediaInfo();
// 获取新闻资讯
fetchNewsInfo();
const [dataCount, entitiesDataInfo, industryCountByYear, cclList] = await Promise.all([
getDataCount(),
getLatestEntityListInfo(),
getReleaseCount(2)
// getReleaseCount(3)
]);
// 交换第二个和第三个元素
// [dataCount[1], dataCount[2]] = [dataCount[2], dataCount[1]];
console.log("dataCount", dataCount);
infoList.value = dataCount.slice(0, 2).map((item, idx) => {
return {
......@@ -1874,6 +1881,11 @@ onMounted(async () => {
tags: item.domain
};
});
await fetchSanctionProcess(sanctionPage.value, 10);
// 获取雷达图数据
await fetchRadarData(domainChecked.value);
// 获取出口管制制裁措施
await fetchSanctionList();
entityListReleaseFreqChart.interpret({
type: "柱状图",
name: "美国商务部发布实体清单的频次",
......@@ -1892,21 +1904,6 @@ onMounted(async () => {
name: "美国商务部发布商业管制清单的频次",
data: commerceControlListReleaseFreq.value
});
// 获取趋势图数据
fetchTrendData();
fetchRiskSignals("0109");
// 获取社交媒体信息
fetchSocialMediaInfo();
// 获取新闻资讯
fetchNewsInfo();
// fetchEntitiesList(currentPage.value, pageSize.value);
await fetchSanctionProcess(sanctionPage.value, 10);
// 获取雷达图数据
await fetchRadarData(domainChecked.value);
// 获取出口管制制裁措施
await fetchSanctionList();
} catch (err) {
console.log("此处报错?");
console.log(err);
......@@ -1957,11 +1954,12 @@ const handleMediaClick = item => {
flex-direction: column;
gap: 20px;
position: relative;
width: 1036px;
.box1-left-arrow {
position: absolute;
z-index: 9999;
left: -20px;
left: -24px;
top: 135px;
width: 24px !important;
height: 48px;
......@@ -1985,7 +1983,7 @@ const handleMediaClick = item => {
.box1-right-arrow {
position: absolute;
z-index: 9999;
right: -20px;
right: 0px;
top: 135px;
width: 24px;
height: 48px;
......@@ -2050,6 +2048,10 @@ const handleMediaClick = item => {
color: $base-color;
margin-top: 10px;
margin-bottom: 15px;
max-width: 80%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&-content {
......
import { ref } from "vue";
export const useChartInterpretation = () => {
const loading = ref(false);
const interpretation = ref("");
const error = ref(null);
const interpret = async text => {
loading.value = true;
error.value = null;
interpretation.value = "";
try {
const response = await fetch("/aiAnalysis/chart_interpretation", {
method: "POST",
headers: {
"X-API-Key": "aircasKEY19491001",
"Content-Type": "application/json"
},
body: JSON.stringify({ text })
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const content = line.substring(6);
const textMatch = content.match(/"解读":\s*"([^"]*)"/);
if (textMatch && textMatch[1]) {
interpretation.value = textMatch[1];
}
}
}
}
} catch (err) {
error.value = err.message || "AI 解读失败";
console.error("AI Chart Interpretation Error:", err);
} finally {
loading.value = false;
}
};
return {
loading,
interpretation,
error,
interpret
};
};
// src/views/finance/utils/common.js
import { ref } from "vue";
// 【新增】引入获取全局 Signal 的方法
import { getAbortSignal } from "@/api/finance/service.js";
export const useChartInterpretation = () => {
const loading = ref(false);
......@@ -10,14 +14,20 @@ export const useChartInterpretation = () => {
error.value = null;
interpretation.value = "";
// 【新增】在请求发起前获取当前的 Signal
// 注意:必须在每次调用 interpret 时重新获取,以确保拿到最新的 controller 的 signal
const signal = getAbortSignal();
try {
// 【修改】在 fetch 中传入 signal
const response = await fetch("/aiAnalysis/chart_interpretation", {
method: "POST",
headers: {
"X-API-Key": "aircasKEY19491001",
"X-API-Key": "aircasKEY19491001",
"Content-Type": "application/json"
},
body: JSON.stringify({ text })
body: JSON.stringify({ text }),
signal: signal // 【关键】绑定取消信号
});
if (!response.ok) {
......@@ -29,6 +39,7 @@ export const useChartInterpretation = () => {
let buffer = "";
while (true) {
// reader.read() 会在 signal abort 时抛出 AbortError
const { done, value } = await reader.read();
if (done) break;
......@@ -39,18 +50,43 @@ export const useChartInterpretation = () => {
for (const line of lines) {
if (line.startsWith("data: ")) {
const content = line.substring(6);
const textMatch = content.match(/"解读":\s*"([^"]*)"/);
if (textMatch && textMatch[1]) {
interpretation.value = textMatch[1];
}
// 尝试解析 JSON
try {
const jsonMatch = content.match(/\{.*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
if (parsed["解读"]) {
interpretation.value = parsed["解读"];
}
} else {
// 兼容旧的正则匹配
const textMatch = content.match(/"解读":\s*"([^"]*)"/);
if (textMatch && textMatch[1]) {
interpretation.value = textMatch[1];
}
}
} catch (e) {
// 忽略解析错误
}
}
}
}
} catch (err) {
// 【关键】判断是否是因路由切换导致的取消
if (err.name === 'AbortError') {
console.log('AI 解读请求已取消');
loading.value = false; // 关闭 loading
return; // 直接返回,不设置 error,不弹窗
}
error.value = err.message || "AI 解读失败";
console.error("AI Chart Interpretation Error:", err);
} finally {
loading.value = false;
// 只有在非 AbortError 的情况下,才由 finally 统一关闭 loading
// 如果上面 catch 中已经 return 了,这里不会执行
if (err?.name !== 'AbortError') {
loading.value = false;
}
}
};
......@@ -60,4 +96,4 @@ export const useChartInterpretation = () => {
error,
interpret
};
};
};
\ No newline at end of file
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论