提交 bd14b59f authored 作者: 朱政's avatar 朱政

feat:登录功能和用户信息功能开发,以及风险信号管理列表页样式开发

上级 82883bfb
流水线 #398 已通过 于阶段
in 1 分 39 秒
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,
......
......@@ -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,17 @@ 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
}
// 处理token过期或无效的情况(排除 AI 分析服务:其 401 多为 API Key 问题)
const errUrl = String(error.config?.url || '')
......@@ -118,4 +140,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
}
}
......@@ -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;
}
}
}
}
}
......
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;
}
......
......@@ -64,7 +64,7 @@
</template>
<script setup>
import { ref, watch, onMounted } from "vue";
import { ref, watch, onMounted, onUnmounted } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { setIsLoggedIn } from "@/utils/auth";
......@@ -118,6 +118,22 @@ const loadLoginRemember = () => {
onMounted(() => {
loadLoginRemember();
// 登录页禁止出现任何滚动条(仅在本页生效)
try {
document.documentElement.classList.add("login-no-scroll");
document.body.classList.add("login-no-scroll");
} catch (_) {
// ignore
}
});
onUnmounted(() => {
try {
document.documentElement.classList.remove("login-no-scroll");
document.body.classList.remove("login-no-scroll");
} catch (_) {
// ignore
}
});
/** 取消记住我:清空本地存储与输入框,刷新/再次进入也为空 */
......@@ -161,6 +177,12 @@ const handleLogin = () => {
}
setIsLoggedIn(true);
// 登录成功:清除“强制回登录页”标记(否则跨页/新开标签页会一直被路由守卫拦截到 /login)
try {
window.localStorage.removeItem("force_login");
} catch (_) {
// ignore
}
if (checked.value) {
saveLoginRemember(u, p);
......@@ -180,12 +202,16 @@ const handleLogin = () => {
</script>
<style scoped lang="scss">
:global(html.login-no-scroll),
:global(body.login-no-scroll) {
overflow: hidden !important;
}
.login-page {
/* 允许纵向滚动,禁止横向滚动(放大时避免横向滚动条) */
/* 登录页不允许出现任何滚动条 */
width: 100vw;
min-height: 100vh;
overflow-x: hidden;
overflow-y: auto;
height: 100vh;
overflow: hidden;
/* 背景色(图片加载不出来时兜底) */
background: rgb(5, 95, 194);
......
......@@ -62,129 +62,104 @@
<div class="home-main-footer">
<div class="home-main-footer-main">
<div class="left">
<div class="left-box1">
<div class="left-box1-header">
<div class="select-box">
<div class="header">
<div class="icon"></div>
<div class="title">{{ "风险类型" }}</div>
<div class="title">{{ "科技领域" }}</div>
</div>
<div class="left-box1-main">
<el-checkbox
v-model="isRiskTypeCheckedAll"
:indeterminate="isRiskTypeIndeterminate"
@change="handleRiskTypeCheckAllChange"
class="checkbox-all"
>
全部类型
</el-checkbox>
<el-checkbox-group
class="checkbox-group"
v-model="checkedRiskType"
@change="handleRiskTypeChange"
>
<el-checkbox
v-for="(item, index) in riskType"
:key="index"
:label="item"
class="filter-checkbox"
>
{{ item }}
<div class="select-main">
<el-checkbox-group class="checkbox-group" :model-value="selectedAreaModel"
@change="handleAreaGroupChange">
<el-checkbox class="filter-checkbox all-checkbox" :label="RISK_FILTER_ALL_AREA">
{{ RISK_FILTER_ALL_AREA }}
</el-checkbox>
<el-checkbox v-for="item in area" :key="item.id" class="filter-checkbox" :label="item.id">
{{ item.name }}
</el-checkbox>
</el-checkbox-group>
</div>
</div>
<div class="left-box1">
<div class="left-box1-header">
<div class="select-box">
<div class="header">
<div class="icon"></div>
<div class="title">{{ "风险来源" }}</div>
<div class="title">{{ "发布时间" }}</div>
</div>
<div class="left-box1-main">
<el-checkbox
v-model="isRiskSourceCheckedAll"
:indeterminate="isRiskSourceIndeterminate"
@change="handleRiskSourceCheckAllChange"
class="checkbox-all"
>
全部来源
</el-checkbox>
<el-checkbox-group
class="checkbox-group"
v-model="checkedRiskSource"
@change="handleRiskSourceChange"
>
<el-checkbox
v-for="item in riskSource"
:key="item.id"
:label="item.id"
class="filter-checkbox"
>
<div class="select-main">
<el-checkbox-group class="checkbox-group" :model-value="selectedTimeModel"
@change="handleTimeGroupChange">
<el-checkbox class="filter-checkbox all-checkbox" :label="Time_FILTER_ALL_SOURCE">
{{ Time_FILTER_ALL_SOURCE }}
</el-checkbox>
<el-checkbox v-for="item in timeSource" :key="item.id" class="filter-checkbox" :label="item.id">
{{ item.name }}
</el-checkbox>
</el-checkbox-group>
</div>
</div>
<div class="left-box1">
<div class="left-box1-header">
<div class="select-box">
<div class="header">
<div class="icon"></div>
<div class="title">{{ "风险等级" }}</div>
<div class="title">{{ "风险来源" }}</div>
</div>
<div class="left-box1-main">
<el-checkbox
v-model="isRiskDegreeCheckedAll"
:indeterminate="isRiskDegreeIndeterminate"
@change="handleRiskDegreeCheckAllChange"
class="checkbox-all"
>
全部等级
</el-checkbox>
<el-checkbox-group
class="checkbox-group"
v-model="checkedRiskDegree"
@change="handleRiskDegreeChange"
>
<el-checkbox
v-for="item in riskDegree"
:key="item.id"
:label="item.name"
class="filter-checkbox"
>
<div class="select-main">
<el-checkbox-group class="checkbox-group" :model-value="selectedRiskSourceModel"
@change="handleRiskSourceGroupChange">
<el-checkbox class="filter-checkbox all-checkbox" :label="RISK_FILTER_ALL_SOURCE">
{{ RISK_FILTER_ALL_SOURCE }}
</el-checkbox>
<el-checkbox v-for="item in riskSource" :key="item.id" class="filter-checkbox" :label="item.id">
{{ item.name }}
</el-checkbox>
</el-checkbox-group>
</div>
</div>
<div class="left-box1">
<div class="left-box1-header">
<div class="select-box">
<div class="header">
<div class="icon"></div>
<div class="title">{{ "涉及领域" }}</div>
<div class="title">{{ "风险类型" }}</div>
</div>
<div class="left-box1-main">
<el-checkbox
v-model="isAreaCheckedAll"
:indeterminate="isAreaIndeterminate"
@change="handleAreaCheckAllChange"
class="checkbox-all"
>
全部领域
</el-checkbox>
<el-checkbox-group class="checkbox-group" v-model="checkedArea" @change="handleAreaChange">
<el-checkbox v-for="item in area" :key="item.id" :label="item.name" class="filter-checkbox">
<div class="select-main">
<el-checkbox-group class="checkbox-group" :model-value="selectedRiskTypeModel"
@change="handleRiskTypeGroupChange">
<el-checkbox class="filter-checkbox all-checkbox" :label="RISK_FILTER_ALL_TYPE">
{{ RISK_FILTER_ALL_TYPE }}
</el-checkbox>
<el-checkbox v-for="item in riskType" :key="item" class="filter-checkbox" :label="item">
{{ item }}
</el-checkbox>
</el-checkbox-group>
</div>
</div>
<div class="select-box">
<div class="header">
<div class="icon"></div>
<div class="title">{{ "风险等级" }}</div>
</div>
<div class="select-main">
<el-checkbox-group class="checkbox-group" :model-value="selectedRiskDegreeModel"
@change="handleRiskDegreeGroupChange">
<el-checkbox class="filter-checkbox all-checkbox" :label="RISK_FILTER_ALL_LEVEL">
{{ RISK_FILTER_ALL_LEVEL }}
</el-checkbox>
<el-checkbox v-for="item in riskDegree" :key="item.id" class="filter-checkbox" :label="item.id">
{{ item.name }}
</el-checkbox>
</el-checkbox-group>
</div>
</div>
</div>
<div class="right">
<div class="right-header">
<div class="header-left">
<div
class="btn-left"
:class="{ btnleftactive: activeProcessStatusId === item.id }"
@click="handleClickBtn(item)"
v-for="item in processStatusList"
:key="item.id"
>
<div class="btn-left" :class="{ btnleftactive: activeProcessStatusId === item.id }"
@click="handleClickBtn(item)" v-for="item in processStatusList" :key="item.id">
<div class="btn-text" :class="{ btntextactive: activeProcessStatusId === item.id }">
{{ item.name }}
</div>
......@@ -192,26 +167,24 @@
</div>
<div class="header-right">
<div class="searchbox">
<el-input
@keyup.enter="handleSearch"
v-model="kewword"
style="width: 268px; height: 100%"
placeholder="搜索"
/>
<el-input @keyup.enter="handleSearch" v-model="kewword" style="width: 268px; height: 100%"
placeholder="搜索" />
<div class="search-btn" @click="handleSearch"></div>
</div>
<div class="select-box">
<div class="paixu-btn" @click="handleSwithSort">
<div class="icon1">
<img v-if="isSort" src="@/assets/icons/shengxu1.png" alt="" />
<img v-else src="@/assets/icons/jiangxu1.png" alt="" />
</div>
<div class="text">{{ "发布时间" }}</div>
<div class="icon2">
<img v-if="isSort" src="@/assets/icons/shengxu2.png" alt="" />
<img v-else src="@/assets/icons/jiangxu2.png" alt="" />
</div>
</div>
<el-select v-model="sortModel" class="resource-library-sort-select" placeholder="发布时间"
style="width: 120px" :teleported="true" placement="bottom-start"
:popper-options="resourceLibrarySortPopperOptions" @change="handleSortChange">
<template #prefix>
<img v-if="sortModel !== true"
src="@/views/thinkTank/ThinkTankDetail/thinkDynamics/images/image down.png"
class="resource-library-sort-prefix-img" alt="" @click.stop="toggleSortPrefix" />
<img v-else src="@/views/thinkTank/ThinkTankDetail/thinkDynamics/images/image up.png"
class="resource-library-sort-prefix-img" alt="" @click.stop="toggleSortPrefix" />
</template>
<el-option :key="'risk-sort-asc'" label="正序" :value="true" />
<el-option :key="'risk-sort-desc'" label="倒序" :value="false" />
</el-select>
</div>
</div>
</div>
......@@ -220,49 +193,40 @@
<div class="itemlist" v-for="(val, idx) in riskList" :key="idx">
<div class="box-title">
<div class="risktitle">{{ val.title }}</div>
<div
class="risktype"
:class="{
risk1: val.risktype === '特别重大风险' || val.risktype === '特别重大',
risk2: val.risktype === '重大风险',
risk3: val.risktype === '一般风险'
}"
>
<div
class="icon"
:class="{
icon1: val.risktype === '特别重大风险' || val.risktype === '特别重大',
icon2: val.risktype === '重大风险',
icon3: val.risktype === '一般风险'
}"
></div>
<div class="risktype" :class="{
risk1: val.risktype === '特别重大风险' || val.risktype === '特别重大风险',
risk2: val.risktype === '重大风险',
risk3: val.risktype === '一般风险'
}">
<div class="icon" :class="{
icon1: val.risktype === '特别重大风险' || val.risktype === '特别重大风险',
icon2: val.risktype === '重大风险',
icon3: val.risktype === '一般风险'
}"></div>
<div class="text">{{ val.risktype }}</div>
</div>
</div>
<div class="box-source">
<img class="source-pic" :src="val.pic ? val.pic : DefaultIcon2" alt="" />
<div class="source-text">{{ val.origin + " · " + val.fileType }}</div>
<img class="source-pic" :src="DefaultIcon2" alt="" />
<div class="source-text">{{ val.origin }}</div>
<div class="source-text">{{ val.time }}</div>
</div>
<div class="desc-box">{{ val.dsc }}</div>
<div class="tag-box" v-if="val.tag.length">
<div class="tag" v-for="(tag, index) in val.tag" :key="index">{{ tag }}</div>
<AreaTag v-for="(tag, index) in val.tag" :key="index" :tagName="tag">{{ tag }}</AreaTag>
</div>
</div>
</div>
<div class="right-footer">
<div class="footer-left">
{{ `共 ${totalNum} 项` }}
{{ `共 ${totalNum} 项调查` }}
</div>
<div class="footer-right">
<el-pagination
@current-change="handleCurrentChange"
:pageSize="pageSize"
:current-page="currentPage"
:total="totalNum"
background
layout="prev, pager, next"
/>
<el-pagination @current-change="handleCurrentChange" :pageSize="pageSize" :current-page="currentPage"
:total="totalNum" background layout="prev, pager, next" />
</div>
</div>
</div>
......@@ -275,26 +239,38 @@
<script setup>
import { onMounted, ref } from "vue";
import { getCountInfo, getDailyCount, getPageQuery } from "@/api/riskSignal/index";
import { getHylyList } from "@/api/thinkTank/overview";
import setChart from "@/utils/setChart";
import getCalendarHeatChart from "./utils/cleandarHeat";
import DefaultIcon2 from "@/assets/icons/default-icon2.png";
import { normalizeExclusiveAllOption } from "@/views/thinkTank/utils/resourceLibraryFilters";
const riskType = ref(["科技法案", "行政令", "智库报告", "出口管制", "投融资限制", "市场准入限制", "规则限制"]);
const isRiskTypeCheckedAll = ref(false);
const isRiskTypeIndeterminate = ref(true);
const checkedRiskType = ref([]);
const handleRiskTypeCheckAllChange = val => {
checkedRiskType.value = val ? riskType.value : [];
isRiskTypeIndeterminate.value = false;
const RISK_FILTER_ALL_TYPE = "全部类型";
const selectedRiskTypeModel = ref([RISK_FILTER_ALL_TYPE]);
const handleRiskTypeGroupChange = (val) => {
selectedRiskTypeModel.value = normalizeExclusiveAllOption(val, RISK_FILTER_ALL_TYPE);
handleGetPageQuery();
};
const handleRiskTypeChange = val => {
const checkedCount = val.length;
isRiskTypeCheckedAll.value = checkedCount === riskType.value.length;
isRiskTypeIndeterminate.value = checkedCount > 0 && checkedCount < riskType.value.length;
// 发布时间(与「全部时间」互斥)
const Time_FILTER_ALL_SOURCE = "全部时间";
const TIME_FILTER_1M = "TIME_FILTER_1M";
const TIME_FILTER_3M = "TIME_FILTER_3M";
const TIME_FILTER_6M = "TIME_FILTER_6M";
const TIME_FILTER_1Y = "TIME_FILTER_1Y";
const timeSource = ref([
{ id: TIME_FILTER_1M, name: "近一个月" },
{ id: TIME_FILTER_3M, name: "近三个月" },
{ id: TIME_FILTER_6M, name: "近半年" },
{ id: TIME_FILTER_1Y, name: "近一年" }
]);
const selectedTimeModel = ref([Time_FILTER_ALL_SOURCE]);
const handleTimeGroupChange = (val) => {
selectedTimeModel.value = normalizeExclusiveAllOption(val, Time_FILTER_ALL_SOURCE);
handleGetPageQuery();
};
......@@ -328,30 +304,17 @@ const riskSource = ref([
id: "0401"
}
]);
const isRiskSourceCheckedAll = ref(false);
const isRiskSourceIndeterminate = ref(true);
const checkedRiskSource = ref([]);
const handleRiskSourceCheckAllChange = val => {
checkedRiskSource.value = val
? riskSource.value.map(item => {
return item.id;
})
: [];
isRiskSourceIndeterminate.value = false;
handleGetPageQuery();
};
const handleRiskSourceChange = val => {
const checkedCount = val.length;
isRiskSourceCheckedAll.value = checkedCount === riskSource.value.length;
isRiskSourceIndeterminate.value = checkedCount > 0 && checkedCount < riskSource.value.length;
const RISK_FILTER_ALL_SOURCE = "全部国家";
const selectedRiskSourceModel = ref([RISK_FILTER_ALL_SOURCE]);
const handleRiskSourceGroupChange = (val) => {
selectedRiskSourceModel.value = normalizeExclusiveAllOption(val, RISK_FILTER_ALL_SOURCE);
handleGetPageQuery();
};
const riskDegree = ref([
{
name: "特别重大",
id: "特别重大"
name: "特别重大风险",
id: "特别重大风险"
},
{
name: "重大风险",
......@@ -366,110 +329,40 @@ const riskDegree = ref([
id: "一般风险"
},
{
name: "风险",
id: "风险"
name: "风险",
id: "风险"
}
]);
const isRiskDegreeCheckedAll = ref(false);
const isRiskDegreeIndeterminate = ref(true);
const checkedRiskDegree = ref([]);
const handleRiskDegreeCheckAllChange = val => {
checkedRiskDegree.value = val
? riskDegree.value.map(item => {
return item.id;
})
: [];
isRiskDegreeIndeterminate.value = false;
handleGetPageQuery();
};
const handleRiskDegreeChange = val => {
const checkedCount = val.length;
isRiskDegreeCheckedAll.value = checkedCount === riskDegree.value.length;
isRiskDegreeIndeterminate.value = checkedCount > 0 && checkedCount < riskDegree.value.length;
const RISK_FILTER_ALL_LEVEL = "全部等级";
const selectedRiskDegreeModel = ref([RISK_FILTER_ALL_LEVEL]);
const handleRiskDegreeGroupChange = (val) => {
selectedRiskDegreeModel.value = normalizeExclusiveAllOption(val, RISK_FILTER_ALL_LEVEL);
handleGetPageQuery();
};
const area = ref([
{
name: "人工智能",
id: 1
},
{
name: "生物科技",
id: 2
},
{
name: "新一代通信技术",
id: 3
},
{
name: "量子科技",
id: 4
},
{
name: "新能源",
id: 5
},
{
name: "集成电路",
id: 6
},
{
name: "海洋",
id: 7
},
{
name: "先进制造",
id: 8
},
{
name: "新材料",
id: 9
},
{
name: "航空航天",
id: 10
},
{
name: "深海",
id: 11
},
{
name: "极地",
id: 12
},
{
name: "太空",
id: 13
},
{
name: "核",
id: 14
},
{
name: "其他",
id: 99
}
]);
const isAreaCheckedAll = ref(false);
const isAreaIndeterminate = ref(true);
const checkedArea = ref([]);
const handleAreaCheckAllChange = val => {
checkedArea.value = val
? area.value.map(item => {
return item.id;
})
: [];
isAreaIndeterminate.value = false;
handleGetPageQuery();
// 科技领域字典:与智库资源库一致
const handleGetHylyList = async () => {
try {
const res = await getHylyList();
if (res.code === 200 && Array.isArray(res.data)) {
area.value = res.data;
} else {
area.value = [];
}
} catch (e) {
console.error("获取科技领域字典失败", e);
area.value = [];
}
};
const handleAreaChange = val => {
const checkedCount = val.length;
isAreaCheckedAll.value = checkedCount === area.value.length;
isAreaIndeterminate.value = checkedCount > 0 && checkedCount < area.value.length;
const RISK_FILTER_ALL_AREA = "全部领域";
const selectedAreaModel = ref([RISK_FILTER_ALL_AREA]);
const handleAreaGroupChange = (val) => {
selectedAreaModel.value = normalizeExclusiveAllOption(val, RISK_FILTER_ALL_AREA);
handleGetPageQuery();
};
......@@ -479,16 +372,13 @@ const processStatusList = ref([
id: -1
},
{
name: "未处理",
name: "未",
id: 0
},
{
name: "处理中",
name: "已读",
id: 1
},
{
name: "已处理",
id: 2
}
]);
const activeProcessStatusId = ref(-1);
......@@ -499,113 +389,75 @@ const handleClickBtn = item => {
};
const riskList = ref([
// {
// title: "扩大实体清单制裁范围,对中企子公司实施同等管制",
// origin: "美国商务部",
// fileType: "实体清单",
// time: "2025年11月10日 16:14",
// dsc: "任何被列入美国出口管制“实体清单”或“军事最终用户清单”的企业,如果其直接或间接持有另一家公司 ​50%或以上的股权,那么这家被控股的公司也将自动受到与清单上母公司同等的出口管制限制",
// tag: ["生物科技", "人工智能"],
// risktype: "特别重大风险",
// pic: "src/views/riskSignal/assets/images/origin1.png"
// }
// {
// title: "大而美法案通过国会众议院投票,将提交至总统签署",
// origin: "美国国会 · 科技法案",
// time: "2025年11月10日 16:14",
// dsc: "",
// tag: ["能源", "人工智能"],
// risktype: "重大风险",
// pic: "src/views/riskSignal/assets/images/origin2.png",
// textcolor: "rgba(255, 149, 77, 1)",
// bgcolor: "rgba(255, 149, 77, 0.1)"
// },
// {
// title: "兰德公司发布智库报告《中美经济竞争:复杂经济和地缘政治关系中的收益和风险》",
// origin: "兰德公司 · 科技智库",
// time: "2025年11月10日 16:14",
// dsc: "包括经济竞争在内的美中竞争自2017年以来一直在定义美国外交政策。这两个经济体是世界上第一和第二大国家经济体,并且深深交织在一起。改变关系,无论多么必要,可能是昂贵的。因此,美国面临着一项挑战,确保其经济在耦合的战略竞争条件下满足国家的需求。",
// tag: ["生物科技", "人工智能", "集成电路"],
// risktype: "一般风险",
// pic: "src/views/riskSignal/assets/images/origin3.png",
// textcolor: "rgba(5, 95, 194, 1)",
// bgcolor: "rgba(5, 95, 194, 0.1)"
// },
// {
// title: "美国白宫发布总统政令《关于进一步延长TikTok执法宽限期的行政令》",
// origin: "美国白宫 · 总统政令",
// time: "2025年11月10日 16:14",
// dsc: "再次推迟(第四次)对TikTok禁令的执法,新的宽限期截止日为2025年12月16日​。在宽限期内及对于宽限期前的行为,司法部不得强制执行​《保护美国人免受外国对手控制应用程序法》或因此处罚相关实体​(如TikTok及其分发平台)。",
// tag: ["人工智能"],
// risktype: "一般风险",
// pic: "src/views/riskSignal/assets/images/origin2.png",
// textcolor: "rgba(5, 95, 194, 1)",
// bgcolor: "rgba(5, 95, 194, 0.1)"
// },
// {
// title: "美国财政部更新《特别指定国民清单》",
// origin: "美国财政部 · 特别指定国民清单",
// time: "2025年11月10日 16:14",
// dsc: "",
// tag: ["生物科技"],
// risktype: "特别重大风险",
// pic: "src/views/riskSignal/assets/images/origin4.png",
// textcolor: "rgba(206, 79, 81, 1)",
// bgcolor: "rgba(206, 79, 81, 0.1)"
// },
// {
// title: "美国FDA针对两家中国第三方检测机构的数据完整性问题采取行动",
// origin: "美国食品药品监督管理局 · 规则限制",
// time: "2025年11月10日 16:14",
// dsc: "FDA因发现数据伪造或无效问题,向两家中国第三方检测公司(天津中联科技检测有限公司和苏州大学卫生与环境技术研究所)正式发出“一般信函”。",
// tag: ["生物科技", "人工智能"],
// risktype: "特别重大风险",
// pic: "src/views/riskSignal/assets/images/origin5.png",
// textcolor: "rgba(206, 79, 81, 1)",
// bgcolor: "rgba(206, 79, 81, 0.1)"
// }
{
title: "扩大实体清单制裁范围,对中企子公司实施同等管制",
origin: "美国商务部",
fileType: "实体清单",
time: "2025年11月10日 16:14",
dsc: "任何被列入美国出口管制“实体清单”或“军事最终用户清单”的企业,如果其直接或间接持有另一家公司 ​50%或以上的股权,那么这家被控股的公司也将自动受到与清单上母公司同等的出口管制限制",
tag: ["生物科技", "人工智能"],
risktype: "特别重大风险",
pic: "src/views/riskSignal/assets/images/origin1.png"
},
{
title: "大而美法案通过国会众议院投票,将提交至总统签署",
origin: "美国国会 · 科技法案",
time: "2025年11月10日 16:14",
dsc: "",
tag: ["能源", "人工智能"],
risktype: "重大风险",
pic: "src/views/riskSignal/assets/images/origin2.png",
textcolor: "rgba(255, 149, 77, 1)",
bgcolor: "rgba(255, 149, 77, 0.1)"
},
{
title: "兰德公司发布智库报告《中美经济竞争:复杂经济和地缘政治关系中的收益和风险》",
origin: "兰德公司 · 科技智库",
time: "2025年11月10日 16:14",
dsc: "包括经济竞争在内的美中竞争自2017年以来一直在定义美国外交政策。这两个经济体是世界上第一和第二大国家经济体,并且深深交织在一起。改变关系,无论多么必要,可能是昂贵的。因此,美国面临着一项挑战,确保其经济在耦合的战略竞争条件下满足国家的需求。",
tag: ["生物科技", "人工智能", "集成电路"],
risktype: "一般风险",
pic: "src/views/riskSignal/assets/images/origin3.png",
textcolor: "rgba(5, 95, 194, 1)",
bgcolor: "rgba(5, 95, 194, 0.1)"
},
{
title: "美国白宫发布总统政令《关于进一步延长TikTok执法宽限期的行政令》",
origin: "美国白宫 · 总统政令",
time: "2025年11月10日 16:14",
dsc: "再次推迟(第四次)对TikTok禁令的执法,新的宽限期截止日为2025年12月16日​。在宽限期内及对于宽限期前的行为,司法部不得强制执行​《保护美国人免受外国对手控制应用程序法》或因此处罚相关实体​(如TikTok及其分发平台)。",
tag: ["人工智能"],
risktype: "一般风险",
pic: "src/views/riskSignal/assets/images/origin2.png",
textcolor: "rgba(5, 95, 194, 1)",
bgcolor: "rgba(5, 95, 194, 0.1)"
},
{
title: "美国财政部更新《特别指定国民清单》",
origin: "美国财政部 · 特别指定国民清单",
time: "2025年11月10日 16:14",
dsc: "",
tag: ["生物科技"],
risktype: "特别重大风险",
pic: "src/views/riskSignal/assets/images/origin4.png",
textcolor: "rgba(206, 79, 81, 1)",
bgcolor: "rgba(206, 79, 81, 0.1)"
},
{
title: "美国FDA针对两家中国第三方检测机构的数据完整性问题采取行动",
origin: "美国食品药品监督管理局 · 规则限制",
time: "2025年11月10日 16:14",
dsc: "FDA因发现数据伪造或无效问题,向两家中国第三方检测公司(天津中联科技检测有限公司和苏州大学卫生与环境技术研究所)正式发出“一般信函”。",
tag: ["生物科技", "人工智能"],
risktype: "特别重大风险",
pic: "src/views/riskSignal/assets/images/origin5.png",
textcolor: "rgba(206, 79, 81, 1)",
bgcolor: "rgba(206, 79, 81, 0.1)"
}
]);
const calendarData = ref([
// ["2025-01-01", 20],
// ["2025-01-05", 120],
// ["2025-01-09", 220],
// ["2025-01-15", 320],
// ["2025-01-20", 120],
// ["2025-01-24", 420],
// ["2025-02-05", 80],
// ["2025-02-08", 280],
// ["2025-02-18", 480],
// ["2025-02-11", 420],
// ["2025-02-21", 320],
// ["2025-03-05", 160],
// ["2025-03-09", 260],
// ["2025-03-19", 460],
// ["2025-03-26", 430],
// ["2025-04-01", 70],
// ["2025-04-05", 170],
// ["2025-04-11", 270],
// ["2025-04-18", 370],
// ["2025-05-05", 210],
// ["2025-05-09", 210],
// ["2025-05-15", 410],
// ["2025-05-22", 480],
// ["2025-06-06", 45],
// ["2025-06-09", 415],
// ["2025-06-16", 245],
// ["2025-06-19", 332],
// ["2025-07-04", 127],
// ["2025-07-09", 327],
// ["2025-07-24", 427],
// ["2025-08-08", 150],
// ["2025-08-11", 250],
// ["2025-08-15", 350],
// ["2025-08-22", 460],
// ["2025-09-10", 480],
// ["2025-09-18", 312],
// ["2025-10-15", 60],
// ["2025-10-19", 80],
// ["2025-10-21", 190]
]);
// 基本统计信息
......@@ -622,7 +474,7 @@ const handleGetCountInfo = async () => {
if (res.code === 200 && res.data) {
basicInfo.value = res.data;
}
} catch (error) {}
} catch (error) { }
};
// 每日统计信息
......@@ -635,7 +487,7 @@ const handleGetDailyCount = async () => {
return [item.day, item.count];
});
}
} catch (error) {}
} catch (error) { }
};
const handleCleandarChart = async () => {
......@@ -644,10 +496,22 @@ const handleCleandarChart = async () => {
setChart(chartCalendar, "chartCalendar");
};
//
const isSort = ref(true); // true 升序 false 倒序
const handleSwithSort = () => {
isSort.value = !isSort.value;
/** null:占位「发布时间」且默认倒序;true 正序;false 倒序(显式选中),与智库概览资源库一致 */
const sortModel = ref(null);
const resourceLibrarySortPopperOptions = {
modifiers: [
{ name: "preventOverflow", options: { mainAxis: false, altAxis: false } },
{ name: "flip", enabled: false }
]
};
const toggleSortPrefix = () => {
sortModel.value = sortModel.value === true ? false : true;
handleSortChange();
};
const handleSortChange = () => {
handleGetPageQuery();
};
......@@ -658,7 +522,7 @@ const handleSearch = async () => {
};
// 风险信号总数
const totalNum = ref(0);
const totalNum = ref(6);
const currentPage = ref(1);
const pageSize = ref(10);
// 处理页码改变事件
......@@ -669,31 +533,61 @@ const handleCurrentChange = page => {
// 按条件分页查询风险信号信息
const handleGetPageQuery = async () => {
const stripAll = (list, allLabel) => (Array.isArray(list) ? list.filter((x) => x !== allLabel) : []);
// 选中「全部xxx」时,传空数组表示不按该条件过滤(与之前未勾选时语义一致)
const riskTypes = stripAll(selectedRiskTypeModel.value, RISK_FILTER_ALL_TYPE);
const srcCountryList = stripAll(selectedRiskSourceModel.value, RISK_FILTER_ALL_SOURCE);
const riskLevels = stripAll(selectedRiskDegreeModel.value, RISK_FILTER_ALL_LEVEL);
const techDomainIds = stripAll(selectedAreaModel.value, RISK_FILTER_ALL_AREA);
const timeFilters = stripAll(selectedTimeModel.value, Time_FILTER_ALL_SOURCE);
const pad2 = (n) => String(n).padStart(2, "0");
const formatYmd = (d) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
const buildStartDateFromTimeFilter = () => {
if (!timeFilters.length) return "";
// 单选时间窗:取第一个即可(normalizeExclusiveAllOption 已保证互斥)
const id = timeFilters[0];
const now = new Date();
const start = new Date(now);
if (id === TIME_FILTER_1M) start.setMonth(start.getMonth() - 1);
else if (id === TIME_FILTER_3M) start.setMonth(start.getMonth() - 3);
else if (id === TIME_FILTER_6M) start.setMonth(start.getMonth() - 6);
else if (id === TIME_FILTER_1Y) start.setFullYear(start.getFullYear() - 1);
else return "";
return formatYmd(start);
};
const startDate = buildStartDateFromTimeFilter();
const endDate = timeFilters.length ? formatYmd(new Date()) : "";
let params;
if (activeProcessStatusId.value === -1) {
params = {
riskTypes: checkedRiskType.value,
srcCountryList: checkedRiskSource.value,
riskLevels: checkedRiskDegree.value,
techDomainIds: checkedArea.value,
riskTypes,
srcCountryList,
riskLevels,
techDomainIds,
startDate,
endDate,
keywords: kewword.value,
pageNum: currentPage.value,
pageSize: pageSize.value,
sortField: "time",
sortOrder: isSort ? "asc" : "desc"
sortOrder: sortModel.value === true ? "asc" : "desc"
};
} else {
params = {
riskTypes: checkedRiskType.value,
srcCountryList: checkedRiskSource.value,
riskLevels: checkedRiskDegree.value,
techDomainIds: checkedArea.value,
riskTypes,
srcCountryList,
riskLevels,
techDomainIds,
dealStatus: activeProcessStatusId.value,
startDate,
endDate,
keywords: kewword.value,
pageNum: 1,
pageSize: 10,
sortField: "time",
sortOrder: isSort ? "asc" : "desc"
sortOrder: sortModel.value === true ? "asc" : "desc"
};
}
......@@ -723,6 +617,7 @@ const handleGetPageQuery = async () => {
onMounted(async () => {
handleGetCountInfo();
handleCleandarChart();
await handleGetHylyList();
handleGetPageQuery();
});
</script>
......@@ -738,13 +633,16 @@ onMounted(async () => {
overflow: hidden;
overflow-y: auto;
background: rgba(248, 249, 250, 1);
.home-main {
width: 100%;
height: 100%;
margin: 0 auto;
background-size: 100% 100%;
.home-main-center {
margin-top: 34px;
.center-center {
margin: 0 auto;
margin-top: 24px;
......@@ -753,18 +651,22 @@ onMounted(async () => {
border-radius: 10px;
box-shadow: 0px 0px 15px 0px rgba(60, 87, 126, 0.2);
background: rgba(255, 255, 255, 1);
.center-header {
display: flex;
justify-content: space-between;
.center-header-left {
margin-left: 30px;
display: flex;
margin-top: 15px;
.iconstyle {
width: 27px;
height: 24px;
margin-top: 3px;
}
.center-header-title {
margin-left: 10px;
font-size: 24px;
......@@ -772,9 +674,10 @@ onMounted(async () => {
line-height: 32px;
}
}
.center-header-right {
margin-top: 16px;
margin-right: 59px;
margin-top: 20px;
margin-right: 36px;
width: 118px;
height: 36px;
border-radius: 6px;
......@@ -784,10 +687,12 @@ onMounted(async () => {
justify-content: center;
align-items: center;
cursor: pointer;
.img {
width: 16px;
height: 15px;
}
.text {
margin-left: 8px;
font-size: 16px;
......@@ -797,25 +702,33 @@ onMounted(async () => {
}
}
}
.center-middle {
display: flex;
.center-middle-left {
display: flex;
flex-direction: column;
.lineitem {
display: flex;
justify-content: space-between;
.item {
margin-left: 61px;
margin-top: 33px;
margin-top: 28px;
margin-bottom: 3px;
width: 111px;
height: 66px;
display: flex;
flex-direction: column;
justify-content: flex-end;
.top {
display: flex;
justify-content: flex-end;
padding-right: 2px;
.dot {
width: 6px;
height: 6px;
......@@ -823,6 +736,7 @@ onMounted(async () => {
margin-right: 7px;
border-radius: 3px;
}
.text1 {
font-size: 16px;
font-weight: 400;
......@@ -831,130 +745,145 @@ onMounted(async () => {
text-align: right;
}
}
.text2 {
font-size: 32px;
font-weight: 700;
line-height: 42px;
text-align: right;
/* 强制不换行 */
white-space: nowrap;
}
}
}
}
.center-middle-right {
width: 1159px;
height: 189px;
box-sizing: border-box;
border: 1px solid rgba(234, 236, 238, 1);
border-radius: 4px;
margin: 30px auto 0;
margin-top: 16px;
margin-left: 61px;
}
}
}
}
.home-main-footer {
// width: 100%;
// height: 1059px;
.home-main-footer-header {
width: 1600px;
height: 42px;
margin: 36px auto;
// background: orange;
display: flex;
justify-content: space-between;
.btn-box {
width: 1000px;
display: flex;
.btn {
color: rgba(95, 101, 108, 1);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 42px;
padding: 0 24px;
border-radius: 21px;
background: rgba(20, 89, 187, 0);
margin-right: 20px;
cursor: pointer;
&:hover {
background: rgba(20, 89, 187, 0.1);
}
}
.btnActive {
padding: 0 24px;
border-radius: 21px;
background: rgba(20, 89, 187, 1);
color: #fff;
&:hover {
color: #fff;
background: rgba(20, 89, 187, 1);
}
}
}
}
.home-main-footer-main {
width: 1600px;
margin-bottom: 30px;
// box-shadow: 0px 0px 15px 0px rgba(60, 87, 126, 0.2);
// background: rgba(255, 255, 255, 1);
margin: 30px auto;
/* 意思:上30px / 左右auto / 下300px */
box-sizing: border-box;
// padding: 20px;
display: flex;
.left {
width: 389px;
height: 950px;
width: 388px;
border-radius: 10px;
box-shadow: 0px 0px 15px 0px rgba(60, 87, 126, 0.2);
padding-bottom: 24px;
box-sizing: border-box;
border: 1px solid rgba(234, 236, 238, 1);
box-shadow: 0px 0px 20px 0px rgba(94, 95, 95, 0.1);
background: rgba(255, 255, 255, 1);
.left-box1 {
margin-top: 17px;
height: 100%;
.left-box1-header {
.select-box {
margin-top: 16px;
.header {
display: flex;
gap: 17px;
.icon {
margin-top: 4px;
width: 8px;
height: 16px;
margin-top: 4px;
border-radius: 0px 4px 4px 0;
background: var(--color-main-active);
border-radius: 0 4px 4px 0;
}
.title {
height: 2px;
margin-left: 17px;
height: 24px;
color: var(--color-main-active);
font-family: Microsoft YaHei;
font-family: "Source Han Sans CN";
font-size: 16px;
font-weight: 700;
line-height: 24px;
letter-spacing: 1px;
text-align: left;
}
}
.left-box1-main {
margin-top: 10px;
.select-main {
margin-left: 25px;
margin-top: 12px;
.checkbox-group {
display: grid;
grid-template-columns: repeat(2, 160px);
gap: 8px 4px;
:deep(.all-checkbox) {
width: 160px;
height: 24px;
margin: 0;
}
:deep(.filter-checkbox) {
width: 160px;
height: 24px;
margin-right: 0 !important;
}
:deep(.el-checkbox__label) {
font-family: "Source Han Sans CN", sans-serif;
font-weight: 400;
font-size: 16px;
line-height: 24px;
letter-spacing: 0px;
text-align: justify;
color: rgb(95, 101, 108);
}
}
}
}
}
.right {
margin-left: 16px;
margin-bottom: 24px;
width: 1196px;
height: 1821px;
border-radius: 10px;
box-shadow: 0px 0px 15px 0px rgba(60, 87, 126, 0.2);
background: rgba(255, 255, 255, 1);
.right-header {
height: 60px;
display: flex;
justify-content: space-between;
border-bottom: 1px solid rgba(234, 236, 238, 1);
.header-left {
display: flex;
margin-left: 44px;
gap: 36px;
.btn-left {
display: flex;
height: 60px;
cursor: pointer;
align-items: center;
.btn-text {
font-size: 18px;
font-weight: 400;
......@@ -962,20 +891,24 @@ onMounted(async () => {
color: rgba(59, 65, 75, 1);
text-align: center;
}
.btntextactive {
font-weight: 700;
color: rgba(5, 95, 194, 1);
}
}
.btnleftactive {
border-bottom: 2px solid rgba(5, 95, 194, 1);
}
}
.header-right {
display: flex;
margin-right: 42px;
gap: 12px;
align-items: center;
.searchbox {
display: flex;
......@@ -985,6 +918,7 @@ onMounted(async () => {
border: 1px solid rgba(230, 231, 232, 1);
background: rgba(255, 255, 255, 1);
box-sizing: border-box;
.search-btn {
width: 16px;
height: 16px;
......@@ -993,68 +927,41 @@ onMounted(async () => {
background: url("./assets/images/search-icon.png");
}
}
.select-box {
width: 120px;
height: 32px;
box-sizing: border-box;
.paixu-btn {
display: flex;
width: 120px;
height: 32px;
box-sizing: border-box;
border: 1px solid rgba(230, 231, 232, 1);
border-radius: 4px;
background: rgba(255, 255, 255, 1);
&:hover {
background: var(--color-bg-hover);
}
cursor: pointer;
.icon1 {
width: 11px;
height: 14px;
margin-top: 10px;
margin-left: 9px;
img {
width: 100%;
height: 100%;
}
}
.text {
height: 19px;
color: rgba(95, 101, 108, 1);
font-family: Microsoft YaHei;
font-size: 14px;
font-weight: 400;
line-height: 18px;
letter-spacing: 0px;
text-align: left;
margin-top: 7px;
margin-left: 9px;
}
.icon2 {
width: 10px;
height: 5px;
margin-top: 5px;
margin-left: 13px;
img {
width: 100%;
height: 100%;
}
.resource-library-sort-select {
:deep(.el-select__wrapper) {
height: 32px;
min-height: 32px;
border-radius: 4px;
}
}
.resource-library-sort-prefix-img {
width: 6.72px;
height: 14px;
}
}
}
}
.right-main {
width: 1196px;
min-height: 790px;
min-height: 1667px;
padding-left: 18px;
padding-top: 6px;
.itemlist {
padding-left: 25px;
padding-top: 18px;
padding-top: 16px;
padding-bottom: 16px;
width: 1138px;
display: flex;
margin-left: 18px;
flex-direction: column;
border-bottom: 1px solid rgba(234, 236, 238, 1);
......@@ -1062,12 +969,14 @@ onMounted(async () => {
height: 28px;
display: flex;
justify-content: space-between;
.risktitle {
font-size: 18px;
font-weight: 700;
line-height: 24px;
color: rgba(59, 65, 75, 1);
}
.risktype {
height: 28px;
padding: 0 8px;
......@@ -1079,34 +988,42 @@ onMounted(async () => {
justify-content: center;
align-items: center;
gap: 6px;
.icon {
width: 4px;
height: 4px;
border-radius: 2px;
}
.icon1 {
background: rgba(206, 79, 81, 1);
}
.icon2 {
background: rgba(255, 149, 77, 1);
}
.icon3 {
background: var(--color-main-active);
}
}
.risk1 {
background: rgba(206, 79, 81, 0.1);
color: rgba(206, 79, 81, 1);
}
.risk2 {
background: rgba(255, 149, 77, 0.1);
color: rgba(255, 149, 77, 1);
}
.risk3 {
background: rgba(231, 243, 255, 1);
color: var(--color-main-active);
}
}
.box-source {
margin-top: 6px;
display: flex;
......@@ -1114,10 +1031,12 @@ onMounted(async () => {
align-items: center;
height: 24px;
gap: 10px;
.source-pic {
width: 16px;
height: 16px;
}
.source-text {
font-size: 16px;
font-weight: 400;
......@@ -1125,6 +1044,7 @@ onMounted(async () => {
color: rgba(95, 101, 108, 1);
}
}
.desc-box {
margin-top: 9px;
font-size: 16px;
......@@ -1133,44 +1053,37 @@ onMounted(async () => {
color: rgba(59, 65, 75, 1);
text-align: justify;
}
.tag-box {
height: 28px;
margin-top: 11px;
display: flex;
flex-direction: row;
gap: 8px;
.tag {
height: 28px;
padding: 0 8px;
line-height: 28px;
border-radius: 4px;
background: rgba(231, 243, 255, 1);
color: var(--color-main-active);
font-family: Microsoft YaHei;
font-style: Regular;
font-size: 14px;
font-weight: 400;
letter-spacing: 0px;
}
}
}
}
.right-footer {
display: flex;
height: 90px;
padding-top: 30px;
padding-top: 28px;
justify-content: space-between;
.footer-left {
color: rgba(59, 65, 75, 1);
font-family: Microsoft YaHei;
font-family: "Source Han Sans CN";
font-size: 14px;
font-weight: 400;
line-height: 18px;
margin-left: 24px;
margin-top: 6px;
margin-left: 26px;
margin-top: 4px;
}
.footer-right {
margin-right: 24px;
margin-right: 58px;
}
}
}
......@@ -1178,12 +1091,6 @@ onMounted(async () => {
}
}
}
.checkbox-all {
margin-left: 20px;
width: 260px;
}
.filter-checkbox {
width: 130px;
margin-left: 20px;
}
/* 复选框尺寸由 .checkbox-group 内统一控制,避免重复覆盖 */
</style>
\ No newline at end of file
......@@ -19,13 +19,13 @@ const getCalendarHeatChart = (data) => {
visualMap: {
show: false,
min: 0,
max: 500,
max: 20,
calculable: true,
orient: 'horizontal',
left: 'center',
top: 65,
inRange: {
color: ['rgba(231, 243, 255, 1)', 'rgba(138, 196, 255, 1)', 'rgba(5, 95, 194, 1)']
color: ['rgb(231, 243, 255)', 'rgb(137, 193, 255)', 'rgb(5, 95, 194)']
},
textStyle: {
color: 'rgba(95, 101, 108, 1)'
......@@ -36,7 +36,7 @@ const getCalendarHeatChart = (data) => {
left: 30,
right: 30,
cellSize: ['auto', 20],
range: '2025',
range: '2026',
splitLine: {
show:false
},
......
......@@ -58,9 +58,10 @@ export default defineConfig({
},
'/api': {
target: 'http://8.140.26.4:9085/',
target: 'http://8.140.26.4:9085/',
// target: 'http://192.168.0.4:28080/',
changeOrigin: true,
// 前端仍请求 /api/xxx,实际转发到后端 /api/v2/xxx
rewrite: (path) => path.replace(/^\/api/, '')
// '/api': {
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论