提交 6714a4e9 authored 作者: 朱政's avatar 朱政

feat:核心成员

上级 58537cbd
VITE_BASE_API= '/api' VITE_BASE_API= '/api'
\ No newline at end of file # 图表解读等 /aiAnalysis 代理服务密钥(勿提交到公开仓库时可改为本地 .env.local)
VITE_AI_ANALYSIS_API_KEY=aircasKEY19491001
\ No newline at end of file
# 线上地址 # 线上地址
VITE_BASE_API= '/api' VITE_BASE_API= '/api'
\ No newline at end of file # 图表解读等 AI 服务(与部署环境一致时填写)
VITE_AI_ANALYSIS_API_KEY=aircasKEY19491001
\ No newline at end of file
import request from "@/api/request.js"; import { getToken } from "@/api/request.js";
const CHART_INTERPRETATION_PATH = "/aiAnalysis/chart_interpretation";
/**
* 从接口各类返回结构中取出「解读」纯文本(含流式拼出的 ```json [...] ``` 字符串)
* @param {unknown} raw
* @returns {string}
*/
export function extractChartInterpretationText(raw) {
if (raw == null || raw === "") {
return "";
}
if (Array.isArray(raw)) {
const first = raw[0];
if (first && typeof first === "object") {
const v = first["解读"] ?? first.interpretation;
if (v != null && String(v).trim()) {
return String(v).trim();
}
}
return "";
}
if (typeof raw === "object") {
if (Array.isArray(raw.data)) {
return extractChartInterpretationText(raw.data);
}
if (typeof raw.data === "string") {
return extractChartInterpretationText(raw.data);
}
if (typeof raw.text === "string") {
return extractChartInterpretationText(raw.text);
}
const v = raw["解读"] ?? raw.interpretation;
if (v != null) {
return String(v).trim();
}
for (const key of ["result", "content", "items", "list"]) {
if (Array.isArray(raw[key])) {
const t = extractChartInterpretationText(raw[key]);
if (t) {
return t;
}
}
}
return "";
}
if (typeof raw === "string") {
const t = raw.trim();
if (!t) {
return "";
}
try {
return extractChartInterpretationText(JSON.parse(t));
} catch {
/* 非纯 JSON */
}
const unfenced = t
.replace(/^```(?:json)?\s*/i, "")
.replace(/```\s*$/i, "")
.trim();
try {
return extractChartInterpretationText(JSON.parse(unfenced));
} catch {
/* 继续尝试截取数组 */
}
const arrMatch = unfenced.match(/\[\s*\{[\s\S]*"解读"[\s\S]*\}\s*\]/);
if (arrMatch) {
try {
return extractChartInterpretationText(JSON.parse(arrMatch[0]));
} catch {
/* ignore */
}
}
const byRegex = extractInterpretationByRegex(t);
if (byRegex) {
return byRegex;
}
return "";
}
return "";
}
/**
* 从非严格 JSON 的文本中用正则提取「解读」字段(流式拼完后仍解析失败时的兜底)
* @param {string} s
* @returns {string}
*/
function extractInterpretationByRegex(s) {
if (!s || typeof s !== "string") {
return "";
}
const m = s.match(/"解读"\s*:\s*"((?:[^"\\]|\\.)*)"/);
if (!m || !m[1]) {
return "";
}
return m[1].replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\"/g, '"').replace(/\\\\/g, "\\");
}
/**
* 解析 SSE 文本行(流式或整包响应体)
* @param {string} fullText
* @returns {string}
*/
function parseSseChartInterpretationLines(fullText) {
let textFragments = "";
let lastFromArray = "";
/** 单行 JSON.parse 失败的 data 片段,多为跨多行的同一 JSON(如数组) */
const unparsedPayloads = [];
const lines = fullText.split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) {
continue;
}
const payload = trimmed.slice(5).trim();
if (!payload || payload === "[DONE]") {
continue;
}
let parsed;
try {
parsed = JSON.parse(payload);
} catch {
unparsedPayloads.push(payload);
continue;
}
if (Array.isArray(parsed)) {
const t = extractChartInterpretationText(parsed);
if (t) {
lastFromArray = t;
}
continue;
}
if (parsed && typeof parsed.text === "string") {
textFragments += parsed.text;
}
}
if (lastFromArray) {
return lastFromArray;
}
/* 合并跨多行 data: 的 JSON(例如 data: [ 、 data: { 、 data: "图表标题"…) */
if (unparsedPayloads.length > 0) {
const joinedNewline = unparsedPayloads.join("\n").trim();
for (const candidate of [joinedNewline, unparsedPayloads.join("").trim()]) {
if (!candidate) {
continue;
}
try {
const t = extractChartInterpretationText(JSON.parse(candidate));
if (t) {
return t;
}
} catch {
/* ignore */
}
const t2 = extractChartInterpretationText(candidate);
if (t2) {
return t2;
}
const t3 = extractInterpretationByRegex(candidate);
if (t3) {
return t3;
}
}
}
const fromFragments = extractChartInterpretationText(textFragments);
if (fromFragments) {
return fromFragments;
}
const fragRegex = extractInterpretationByRegex(textFragments);
if (fragRegex) {
return fragRegex;
}
return extractChartInterpretationText(textFragments.trim());
}
/**
* @param {ReadableStream<Uint8Array> | null} body
* @param {(chunk: string) => void} [onDelta]
* @returns {Promise<string>}
*/
async function readSseChartInterpretationStream(body, onDelta) {
if (!body) {
return "";
}
const reader = body.getReader();
const decoder = new TextDecoder();
let all = "";
let lastPushed = "";
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
all += decoder.decode(value, { stream: true });
const now = parseSseChartInterpretationLines(all);
if (now && now !== lastPushed) {
lastPushed = now;
onDelta?.(now);
}
}
all += decoder.decode();
const fin = parseSseChartInterpretationLines(all);
if (fin && fin !== lastPushed) {
onDelta?.(fin);
}
return fin;
}
/**
* POST 图表解读:支持 SSE(data: {"text":"..."} / data: [...])与普通 JSON
* @param {Record<string, unknown>} body
* @param {(interpretation: string) => void} [onInterpretationDelta] 流式过程中有可读解读时回调
* @returns {Promise<string>} 解读正文
*/
export async function fetchChartInterpretation(body, onInterpretationDelta) {
const headers = {
"Content-Type": "application/json",
Accept: "text/event-stream, application/json, text/plain, */*"
};
const token = getToken();
if (token) {
headers.token = token;
}
const aiApiKey = import.meta.env.VITE_AI_ANALYSIS_API_KEY;
if (aiApiKey) {
headers["X-API-Key"] = aiApiKey;
}
const res = await fetch(CHART_INTERPRETATION_PATH, {
method: "POST",
headers,
body: JSON.stringify(body)
});
if (!res.ok) {
const errText = await res.text().catch(() => "");
throw new Error(errText || `Request failed with status ${res.status}`);
}
const contentType = res.headers.get("content-type") || "";
if (contentType.includes("text/event-stream") || contentType.includes("event-stream")) {
return readSseChartInterpretationStream(res.body, onInterpretationDelta);
}
const text = await res.text();
if (/^\s*data:/m.test(text)) {
const out = parseSseChartInterpretationLines(text);
if (out) {
onInterpretationDelta?.(out);
}
return out;
}
let parsed;
try {
parsed = JSON.parse(text);
} catch {
const extracted = extractChartInterpretationText(text);
return extracted || text.trim();
}
const extracted = extractChartInterpretationText(parsed);
if (extracted) {
return extracted;
}
if (typeof parsed === "string") {
return extractChartInterpretationText(parsed) || parsed;
}
return extractChartInterpretationText(text) || text.trim();
}
// 图表解读(流式)
/** /**
* @param {text} * 图表解读(POST /chart_interpretation),兼容流式 SSE 与一次性 JSON
* @param {{ text: string }} data
* @param {{ onInterpretationDelta?: (s: string) => void }} [options]
* @returns {Promise<string>} 解读纯文本
*/ */
export function getChartAnalysis(data) { export async function getChartAnalysis(data, options = {}) {
return request({ const { onInterpretationDelta } = options;
method: 'POST', return fetchChartInterpretation(data, onInterpretationDelta);
url: `/aiAnalysis/chart_interpretation`, }
data,
})
}
\ No newline at end of file
...@@ -51,6 +51,14 @@ service.interceptors.request.use(config => { ...@@ -51,6 +51,14 @@ service.interceptors.request.use(config => {
config.headers['token'] = token config.headers['token'] = token
// config.headers['Authorization'] = `Bearer ${token}` // 如果后端需要Bearer格式可以使用这个 // config.headers['Authorization'] = `Bearer ${token}` // 如果后端需要Bearer格式可以使用这个
} }
// 图表解读等 AI 分析服务(Vite 代理 /aiAnalysis)需要 X-API-Key
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
}
}
return config return config
}, error => { }, error => {
console.log(error) console.log(error)
...@@ -82,8 +90,14 @@ service.interceptors.response.use( ...@@ -82,8 +90,14 @@ service.interceptors.response.use(
// 重复请求触发的取消不提示错误 // 重复请求触发的取消不提示错误
if (isCanceledError) return Promise.reject(error) if (isCanceledError) return Promise.reject(error)
// 处理token过期或无效的情况 // 处理token过期或无效的情况(排除 AI 分析服务:其 401 多为 API Key 问题)
if (error.response && (error.response.status === 401 || error.response.status === 403)) { const errUrl = String(error.config?.url || '')
const isAiAnalysisRequest = errUrl.includes('aiAnalysis')
if (
error.response &&
(error.response.status === 401 || error.response.status === 403) &&
!isAiAnalysisRequest
) {
ElMessage({ ElMessage({
message: 'Token已过期,请重新登录', message: 'Token已过期,请重新登录',
type: 'error', type: 'error',
......
...@@ -35,13 +35,19 @@ export function getThinkTankRiskSignal() { ...@@ -35,13 +35,19 @@ export function getThinkTankRiskSignal() {
}) })
} }
// 政策建议趋势分布 /**
* 政策建议趋势分布(数量变化趋势)
* @param {{ startDate: string, endDate: string }} params - 如 2024-01-01 ~ 2024-12-31
*/
export function getThinkTankPolicyIndustryChange(params) { export function getThinkTankPolicyIndustryChange(params) {
return request({ return request({
method: 'GET', method: "GET",
url: `/api/thinkTankOverview/policyIndustryChange/${params}`, url: `/api/thinkTankOverview/policyIndustryChange`,
params: {
}) startDate: params.startDate,
endDate: params.endDate
}
});
} }
// 政策建议领域分布 // 政策建议领域分布
...@@ -193,15 +199,31 @@ export function getThinkPolicyIndustryChange(params) { ...@@ -193,15 +199,31 @@ export function getThinkPolicyIndustryChange(params) {
}) })
} }
//获取智库政策 /**
* 获取智库政策(政策追踪列表)
* GET /api/thinkTankInfo/policy
* Query: thinkTankId, startDate, endDate, orgIds, domainIds(科技领域/智库领域,逗号分隔 id), pageNum, pageSize, sortField, sortOrder, sortFun, reportId 等
*/
export function getThinkPolicy(params) { export function getThinkPolicy(params) {
return request({ return request({
method: 'GET', method: 'GET',
url: `/api/thinkTankInfo/policy/${params.id}/${params.startDate}`, url: '/api/thinkTankInfo/policy',
params params
}) })
} }
/**
* 政府机构字典(政策追踪-涉及部门筛选项)
* GET /api/commonDict/gov/agency
* @returns {Promise<{ code: number, data: Array<{ id: string, name: string }> }>}
*/
export function getGovAgencyDict() {
return request({
method: 'GET',
url: '/api/commonDict/gov/agency'
})
}
//智库百科基本信息 //智库百科基本信息
export function getThinkTankInfoBasic(params) { export function getThinkTankInfoBasic(params) {
return request({ return request({
......
...@@ -24,17 +24,19 @@ const props = defineProps({ ...@@ -24,17 +24,19 @@ const props = defineProps({
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.tip-wrapper{ .tip-wrapper {
width: 100%; width: 100%;
display: flex; display: flex;
gap: 8px; gap: 8px;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
height: 22px; height: 22px;
.icon{
.icon {
width: 16px; width: 16px;
height: 16px; height: 16px;
img{
img {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
......
...@@ -9,6 +9,10 @@ const setChart = (option, chartId) => { ...@@ -9,6 +9,10 @@ const setChart = (option, chartId) => {
chartDom.removeAttribute("_echarts_instance_"); chartDom.removeAttribute("_echarts_instance_");
let chart = echarts.init(chartDom); let chart = echarts.init(chartDom);
chart.setOption(option); chart.setOption(option);
// 容器可能受布局/异步渲染影响,强制一次 resize 保证 canvas 与容器一致
setTimeout(() => {
chart.resize();
}, 0);
return chart; return chart;
}; };
......
import * as echarts from 'echarts' import * as echarts from 'echarts'
import { size, split } from 'lodash'
// data: [{ name: string, value: number[] }, ...] /** 政策追踪「研究领域变化趋势」图例分页:每页条数(与概览数量变化趋势逻辑一致,条数按产品要求为 4) */
const getMultiLineChart = (dataX, seriesData) => { export const POLICY_TRACKING_LEGEND_PAGE_SIZE = 4
const colorList = [
'rgba(5, 95, 194, 1)',
'rgba(19, 168, 168, 1)',
'rgba(250, 140, 22, 1)',
'rgba(114, 46, 209, 1)',
'rgba(115, 209, 61, 1)',
'rgba(206, 79, 81, 1)',
'rgba(145, 202, 255, 1)',
'rgba(95, 101, 108, 1)',
'rgba(250, 84, 28, 1)',
'rgba(47, 84, 235, 1)',
'rgba(64, 150, 255, 1)',
'rgba(34, 41, 52, 1)',
'rgba(173, 198, 255, 1)',
'rgba(255, 169, 64, 1)'
]
const parseRgba = (colorStr) => {
const match = colorStr.match(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*(\d+(\.\d+)?)\)/)
if (match) {
return {
r: parseInt(match[1], 10),
g: parseInt(match[2], 10),
b: parseInt(match[3], 10),
a: parseFloat(match[4])
}
}
return { r: 0, g: 0, b: 0, a: 1 }
}
/**
* @param {{ title: unknown[], data: Array<{ name: string, value: unknown[], color?: string }> }} chartInput
* @param {{ legendShowCount?: number, legendPageIndex?: number }} [options]
*/
const getMultiLineChart = (chartInput, options = {}) => {
const title = chartInput.title
const series = chartInput.data || []
const legendShowCount =
typeof options.legendShowCount === 'number' && options.legendShowCount > 0
? options.legendShowCount
: POLICY_TRACKING_LEGEND_PAGE_SIZE
const rawPageIndex = Number(options.legendPageIndex) || 0
const allNames = series.map((item) => item.name)
const pageCount = Math.max(1, Math.ceil(allNames.length / legendShowCount))
const legendPageIndex = Math.min(Math.max(0, rawPageIndex), pageCount - 1)
const legendStart = legendPageIndex * legendShowCount
const legendData = allNames.slice(legendStart, legendStart + legendShowCount)
const xCount = Array.isArray(title) ? title.length : 0
const labelFontSize = xCount > 8 ? 10 : xCount > 5 ? 11 : 12
const labelRotate = xCount > 6 ? 28 : 0
const echartsSeries = series.map((item, index) => {
const baseColor =
item.color ||
colorList[index % colorList.length] ||
`rgba(${Math.floor(Math.random() * 256)}, ${Math.floor(Math.random() * 256)}, ${Math.floor(Math.random() * 256)}, 1)`
const { r, g, b } = parseRgba(baseColor)
return {
name: item.name,
type: 'line',
smooth: true,
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: `rgba(${r}, ${g}, ${b}, 0.3)` },
{ offset: 1, color: `rgba(${r}, ${g}, ${b}, 0)` }
])
},
symbolSize: 8,
emphasis: { focus: 'series' },
data: item.value
}
})
return { return {
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
...@@ -13,72 +89,78 @@ const getMultiLineChart = (dataX, seriesData) => { ...@@ -13,72 +89,78 @@ const getMultiLineChart = (dataX, seriesData) => {
} }
} }
}, },
/* 贴满 #box3Chart:四边 0,由 containLabel 在网格内为轴文字留位,避免左侧/底部大块留白 */
grid: { grid: {
top: '15%', top: 50,
right: '5%', right: 10,
bottom: '5%', bottom: 0,
left: '5%', left: 20,
containLabel: true containLabel: true
}, },
legend: { legend: {
icon:'circle',
show: true, show: true,
top: 10, type: 'plain',
left:'10%', data: legendData,
top: 4,
left: 'center',
icon: 'circle',
textStyle: { textStyle: {
fontSize: 16, fontFamily: 'Source Han Sans CN',
fontFamily: 'Source Han Sans CN', fontWeight: 400,
fontWeight: 400, fontSize: 14,
lineHeight: 24, lineHeight: 24,
letterSpacing: 0, letterSpacing: 0,
align: 'left', align: 'left',
color:'rgb(95, 101, 108)' color: 'rgb(95, 101, 108)'
}, },
itemWidth:12, itemWidth: 12,
itemHeight:12, itemHeight: 12
}, },
color: colorList,
xAxis: [ xAxis: [
{ {
type: 'category', type: 'category',
boundaryGap: false, boundaryGap: false,
data: dataX, data: title,
axisLine: { axisLine: {
lineStyle: { lineStyle: {
color: 'rgb(231, 243, 255)', color: 'rgb(231, 243, 255)'
}, }
}, },
axisLabel: { axisLabel: {
color: 'rgb(132, 136, 142)', color: 'rgb(132, 136, 142)',
fontFamily: 'Microsoft YaHei', fontFamily: 'Source Han Sans CN',
fontWeight: 400, fontWeight: 400,
fontSize:12, fontSize: labelFontSize,
interval: 0,
hideOverlap: false,
rotate: labelRotate,
}, margin: 6
}
} }
], ],
yAxis: [ yAxis: [
{ {
type: 'value', type: 'value',
splitLine:{ splitNumber: 4,
show:true, axisLabel: {
lineStyle:{ color: 'rgb(132, 136, 142)',
color:"rgb(231, 243, 255)", fontFamily: 'Source Han Sans CN',
type:'dashed' fontWeight: 400,
fontSize: 11,
margin: 6
},
splitLine: {
show: true,
lineStyle: {
color: 'rgb(231, 243, 255)',
type: 'dashed'
} }
} }
} }
], ],
series: (seriesData || []).map(item => ({ series: echartsSeries
name: item.name,
type: 'line',
emphasis: { focus: 'series' },
data: item.value
}))
} }
} }
export default getMultiLineChart export default getMultiLineChart
\ No newline at end of file
...@@ -180,7 +180,7 @@ ...@@ -180,7 +180,7 @@
</div> </div>
</AnalysisBox> </AnalysisBox>
</div> </div>
<div class="box"> <div class="box box-core-researchers">
<!-- <div class="box-header"> <!-- <div class="box-header">
<div class="header-left"></div> <div class="header-left"></div>
<div class="title">核心研究人员</div> <div class="title">核心研究人员</div>
...@@ -213,16 +213,17 @@ ...@@ -213,16 +213,17 @@
</div> </div>
</div> </div>
</div> --> </div> -->
<AnalysisBox title="核心研究人员"> <AnalysisBox title="核心研究人员" width="1104px" height="900px">
<div class="box3-main"> <div class="box3-main">
<div class="box3-main-left"> <div class="box3-main-top">
<div id="box3Chart"></div> <div id="box3Chart"></div>
<div class="source">
<div class="info"><img src="./images/image-exclamation.png"></div>
<div class="text"> 数据来源:美国国会官网,数据时间:2015.1至2025.12</div>
</div>
</div> </div>
<div class="box3-main-right"> <div class="source">
<div class="info"><img src="./images/image-exclamation.png"></div>
<div class="text"> 数据来源:美国国会官网,数据时间:2015.1至2025.12</div>
</div>
<div class="box3-main-bottom">
<div class="box3-right-item" v-for="(item, index) in box3RightData" :key="index"> <div class="box3-right-item" v-for="(item, index) in box3RightData" :key="index">
<div class="icon" @click="handleClickPerson(item)"> <div class="icon" @click="handleClickPerson(item)">
<img :src="item.imageUrl ? item.imageUrl : DefaultIcon1" alt="" /> <img :src="item.imageUrl ? item.imageUrl : DefaultIcon1" alt="" />
...@@ -244,7 +245,7 @@ ...@@ -244,7 +245,7 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from "vue"; import { ref, onMounted, nextTick } from "vue";
import setChart from "@/utils/setChart"; import setChart from "@/utils/setChart";
import getPieChart from "./utils/piechart"; import getPieChart from "./utils/piechart";
import getTreeMapChart from "./utils/treeMapChart"; import getTreeMapChart from "./utils/treeMapChart";
...@@ -602,7 +603,7 @@ const handleBox3Chart = () => { ...@@ -602,7 +603,7 @@ const handleBox3Chart = () => {
const box3Chart = getTreeMapChart(treemapData); const box3Chart = getTreeMapChart(treemapData);
delete box3Chart.series[0].itemStyle; delete box3Chart.series[0].itemStyle;
box3Chart.series[0].sort = false; box3Chart.series[0].sort = false;
// 图表充满 box3-main-left,左右各留 12px // 图表充满 box3-main-top,左右各留 12px
box3Chart.series[0].left = 12; box3Chart.series[0].left = 12;
box3Chart.series[0].right = 12; box3Chart.series[0].right = 12;
box3Chart.series[0].top = 0; box3Chart.series[0].top = 0;
...@@ -610,11 +611,10 @@ const handleBox3Chart = () => { ...@@ -610,11 +611,10 @@ const handleBox3Chart = () => {
// 方块内文字与区域标题统一为黑色,方块内文字加 1px 白色描边(等效 CSS text-stroke: 1px #fff) // 方块内文字与区域标题统一为黑色,方块内文字加 1px 白色描边(等效 CSS text-stroke: 1px #fff)
box3Chart.series[0].label = { box3Chart.series[0].label = {
...box3Chart.series[0].label, ...box3Chart.series[0].label,
color: 'rgb(51,51,51)', color: 'white',
textBorderColor: '#fff',
textBorderWidth: 0.7, fontSize: 16,
// 可选:白色阴影兜底
textShadowColor: '#fff',
textShadowBlur: 0, textShadowBlur: 0,
textShadowOffsetX: 0, textShadowOffsetX: 0,
textShadowOffsetY: 0 textShadowOffsetY: 0
...@@ -739,7 +739,9 @@ onMounted(() => { ...@@ -739,7 +739,9 @@ onMounted(() => {
handleGetThinkTankResearchAreae() handleGetThinkTankResearchAreae()
handleGetThinkPerson() handleGetThinkPerson()
handleBox3Chart(); nextTick(() => {
handleBox3Chart();
});
}); });
</script> </script>
...@@ -876,7 +878,8 @@ onMounted(() => { ...@@ -876,7 +878,8 @@ onMounted(() => {
.right { .right {
width: 1104px; width: 1104px;
height: 1245px; /* 三栏:390 + 390 + 900,间距 16×2 */
height: 1712px;
margin-top: 16px; margin-top: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
...@@ -885,6 +888,7 @@ onMounted(() => { ...@@ -885,6 +888,7 @@ onMounted(() => {
.box { .box {
width: 1104px; width: 1104px;
height: 390px; height: 390px;
flex-shrink: 0;
// box-sizing: border-box; // box-sizing: border-box;
// border: 1px solid rgba(234, 236, 238, 1); // border: 1px solid rgba(234, 236, 238, 1);
// border-radius: 10px; // border-radius: 10px;
...@@ -1230,59 +1234,62 @@ onMounted(() => { ...@@ -1230,59 +1234,62 @@ onMounted(() => {
.box3-main { .box3-main {
display: flex; display: flex;
flex-direction: column;
.box3-main-left { .box3-main-top {
width: 536px; width: 1100px;
height: 326px; height: 372px;
margin-left: 9px;
box-sizing: border-box;
box-sizing: border-box;
padding-left: 35px;
#box3Chart { #box3Chart {
width: 536px; width: 1035px;
height: 290px; height: 372px;
} }
.source {
margin: 0 auto;
margin-top: 10px;
/* 上下0,左右自动居中 */
width: 370px;
height: 22px;
display: flex;
.info { }
width: 16px;
height: 16px;
margin-top: 3px;
img { .source {
width: 100%; margin: 0 auto;
height: 100%; margin-top: 10px;
} /* 上下0,左右自动居中 */
width: 370px;
height: 22px;
display: flex;
.info {
width: 16px;
height: 16px;
margin-top: 3px;
} img {
width: 100%;
height: 100%;
.text {
font-family: "Source Han Sans CN";
font-weight: 400;
font-size: 14px;
line-height: 22px;
letter-spacing: 0px;
text-align: left;
color: rgb(132, 136, 142);
margin-left: 8px;
} }
} }
.text {
font-family: "Source Han Sans CN";
font-weight: 400;
font-size: 14px;
line-height: 22px;
letter-spacing: 0px;
text-align: left;
color: rgb(132, 136, 142);
margin-left: 8px;
}
} }
.box3-main-right { .box3-main-bottom {
margin-left: 28px; margin-left: 28px;
margin-top: 12px; margin-top: 24px;
width: 536px; width: 536px;
height: 326px; height: 326px;
...@@ -1356,9 +1363,15 @@ onMounted(() => { ...@@ -1356,9 +1363,15 @@ onMounted(() => {
} }
} }
} }
} }
.box.box-core-researchers {
width: 1104px;
height: 900px;
flex-shrink: 0;
}
} }
} }
</style> </style>
\ No newline at end of file
<template>
<div class="tip-wrapper">
<div class="icon">
<img src="./tip-icon.svg" alt="">
</div>
<div class="text text-tip-2 text-primary-50-clor">{{ `${text}` }}</div>
</div>
</template>
<script setup>
const props = defineProps({
dataSource: {
type: String,
default: '美国国会官网'
},
dataTime: {
type: String,
default: '2023.1至2025.12'
},
text: {
type: String,
default: ''
},
})
</script>
<style lang="scss" scoped>
.tip-wrapper {
width: 100%;
display: flex;
gap: 8px;
justify-content: center;
align-items: center;
height: 22px;
.icon {
width: 16px;
height: 16px;
img {
width: 100%;
height: 100%;
}
}
}
</style>
\ No newline at end of file
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16.000000" height="16.000000" fill="none" customFrame="#000000">
<rect id="容器 704" width="16.000000" height="16.000000" x="0.000000" y="0.000000" />
<circle id="椭圆 96" cx="8" cy="8" r="7" fill="rgb(230,231,232)" />
<circle id="椭圆 97" cx="8" cy="4" r="1" fill="rgb(132,136,142)" />
<path id="矩形 241" d="M6.49996 6L8.00028 6.0004C8.55256 6.0004 9.00028 6.44811 9.00028 7.00039L9.00028 10.4992C9.00028 10.7754 9.22408 10.9989 9.50033 10.9992L9.50033 10.9997C9.77657 10.9998 10.0005 11.2236 10.0005 11.4998L10.0003 11.5001C10.0002 11.7765 9.77622 12.0006 9.49978 12.0006L8.00028 12.0004L6.50033 12.0004C6.22423 12.0004 6.00064 11.7767 6.00049 11.5006L6.00021 11.5005C6.00021 11.2243 6.22418 11.0003 6.50037 11.0003L6.50037 11.0006C6.77649 11.0007 7.00042 10.7766 7.00042 10.5005L7.00017 7.50005C7.00017 7.22376 6.77644 7.00047 6.50015 7.00002L6.49946 6.99922C6.22357 6.999 6 6.77565 6 6.49976C6.00011 6.22373 6.22393 6 6.49996 6Z" fill="rgb(132,136,142)" fill-rule="evenodd" />
</svg>
...@@ -24,7 +24,7 @@ ...@@ -24,7 +24,7 @@
</div> </div>
<div class="select-box"> <div class="select-box">
<div class="search-box"> <div class="search-box">
<el-input placeholder="搜索智库" v-model="searchPolicy"> <el-input placeholder="搜索智库" v-model="searchPolicy" @keyup.enter="handleGetThinkTankList()">
<template #suffix> <template #suffix>
<img src="../assets/images/Line_Search.png" class="search-icon" alt="搜索" <img src="../assets/images/Line_Search.png" class="search-icon" alt="搜索"
@click="handleGetThinkTankList()"> @click="handleGetThinkTankList()">
...@@ -70,6 +70,9 @@ ...@@ -70,6 +70,9 @@
{{ "近期美国智库机构发布涉华报告数量汇总" }} {{ "近期美国智库机构发布涉华报告数量汇总" }}
</div> </div>
</div> </div>
<div class="time-tab-pane">
<TimeTabPane @time-click="handleTimeClick" />
</div>
</div> </div>
<div class="all-item"> <div class="all-item">
<div class="item-card" v-for="(item, index) in sortedCardList" :key="item.id || index" <div class="item-card" v-for="(item, index) in sortedCardList" :key="item.id || index"
...@@ -111,6 +114,7 @@ import { ref, reactive, computed, onMounted } from "vue"; ...@@ -111,6 +114,7 @@ import { ref, reactive, computed, onMounted } from "vue";
import { getAllThinkTankList } from "@/api/thinkTank/overview" import { getAllThinkTankList } from "@/api/thinkTank/overview"
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import router from "@/router"; import router from "@/router";
import TimeTabPane from '@/components/base/TimeTabPane/index.vue'
const cardList = ref([]); const cardList = ref([]);
// 按 reportNumber 从大到小排序,保证从左到右从上到下排列 // 按 reportNumber 从大到小排序,保证从左到右从上到下排列
const sortedCardList = computed(() => { const sortedCardList = computed(() => {
...@@ -125,6 +129,22 @@ const sortedCardList = computed(() => { ...@@ -125,6 +129,22 @@ const sortedCardList = computed(() => {
const currentPage = ref(1) const currentPage = ref(1)
const pageSize = ref(15) const pageSize = ref(15)
const total = ref(0) const total = ref(0)
const timePeriod = ref("WEEK")
const handleTimeClick = item => {
const time = item?.time
if (time === "近一周") {
timePeriod.value = "WEEK"
} else if (time === "近一月") {
timePeriod.value = "MONTH"
} else if (time === "近一年") {
timePeriod.value = "YEAR"
}
// 切换时间范围后重新拉取(从第一页开始)
currentPage.value = 1
handleGetThinkTankList()
}
const handleCurrentChange = page => { const handleCurrentChange = page => {
currentPage.value = page; currentPage.value = page;
handleGetThinkTankList() handleGetThinkTankList()
...@@ -142,7 +162,8 @@ const handleGetThinkTankList = async () => { ...@@ -142,7 +162,8 @@ const handleGetThinkTankList = async () => {
// 后端通常是 0-based,这里做一次转换 // 后端通常是 0-based,这里做一次转换
currentPage: currentPage.value - 1, currentPage: currentPage.value - 1,
pageSize: pageSize.value, pageSize: pageSize.value,
keyword: searchPolicy.value keyword: searchPolicy.value,
timePeriod: timePeriod.value
}); });
console.log("智库列表", res); console.log("智库列表", res);
cardList.value = []; cardList.value = [];
...@@ -391,6 +412,7 @@ onMounted(async () => { ...@@ -391,6 +412,7 @@ onMounted(async () => {
height: 36px; height: 36px;
margin-top: 14px; margin-top: 14px;
display: flex; display: flex;
justify-content: space-between;
.title-info { .title-info {
height: 24px; height: 24px;
...@@ -419,6 +441,11 @@ onMounted(async () => { ...@@ -419,6 +441,11 @@ onMounted(async () => {
color: rgb(59, 65, 75); color: rgb(59, 65, 75);
} }
} }
.time-tab-pane {
width: 248px;
height: 36px;
}
} }
.all-item { .all-item {
...@@ -463,7 +490,8 @@ onMounted(async () => { ...@@ -463,7 +490,8 @@ onMounted(async () => {
padding: 2px 8px; padding: 2px 8px;
/* 左右留空隙,更美观 */ /* 左右留空隙,更美观 */
white-space: nowrap white-space: nowrap;
z-index: 9999;
} }
.item-header { .item-header {
......
import * as echarts from 'echarts'; import * as echarts from 'echarts';
const getMultiLineChart = (data) => { /** 图例分页:每页展示的图例项数量(box5 数量变化趋势) */
export const MULTILINE_LEGEND_SHOW_COUNT = 11;
/**
* @param {{ title: unknown[], data: Array<{ name: string, value: unknown[], color?: string }> }} data
* @param {{ legendShowCount?: number, legendPageIndex?: number }} [options]
*/
const getMultiLineChart = (data, options = {}) => {
// 提取标题和系列数据 // 提取标题和系列数据
const title = data.title const title = data.title
const series = data.data const series = data.data
const legendShowCount =
typeof options.legendShowCount === 'number' && options.legendShowCount > 0
? options.legendShowCount
: MULTILINE_LEGEND_SHOW_COUNT
const rawPageIndex = Number(options.legendPageIndex) || 0
const allNames = series.map((item) => item.name)
const pageCount = Math.max(1, Math.ceil(allNames.length / legendShowCount))
const legendPageIndex = Math.min(Math.max(0, rawPageIndex), pageCount - 1)
const legendStart = legendPageIndex * legendShowCount
const legendData = allNames.slice(legendStart, legendStart + legendShowCount)
// 定义配色数组 // 定义配色数组
const colorList = [ const colorList = [
'rgba(5, 95, 194, 1)', // #055fc2 'rgba(5, 95, 194, 1)', // #055fc2
...@@ -48,6 +66,7 @@ const getMultiLineChart = (data) => { ...@@ -48,6 +66,7 @@ const getMultiLineChart = (data) => {
return ({ return ({
name: item.name, name: item.name,
type: 'line', type: 'line',
smooth: true,
// 新增/优化:面积填充渐变效果 // 新增/优化:面积填充渐变效果
areaStyle: { areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
...@@ -80,8 +99,9 @@ const getMultiLineChart = (data) => { ...@@ -80,8 +99,9 @@ const getMultiLineChart = (data) => {
} }
} }
}, },
/* 顶部预留足够空间:多行图例 + Y 轴标题「数量」在纵轴顶端,避免与图例重合 */
grid: { grid: {
top: '15%', top: '28%',
right: '5%', right: '5%',
bottom: '5%', bottom: '5%',
left: '5%', left: '5%',
...@@ -89,10 +109,12 @@ const getMultiLineChart = (data) => { ...@@ -89,10 +109,12 @@ const getMultiLineChart = (data) => {
}, },
legend: { legend: {
show: true, show: true,
top: 10, type: 'plain',
data: legendData,
top: 8,
left: 'center', left: 'center',
icon: 'circle', icon: 'circle',
textStyle: { textStyle: {
fontFamily: 'Source Han Sans CN', // 字体 fontFamily: 'Source Han Sans CN', // 字体
fontWeight: 400, // 字重值(Regular对应400) fontWeight: 400, // 字重值(Regular对应400)
fontSize: 14, // 字号 fontSize: 14, // 字号
...@@ -112,6 +134,22 @@ const getMultiLineChart = (data) => { ...@@ -112,6 +134,22 @@ const getMultiLineChart = (data) => {
yAxis: [ yAxis: [
{ {
type: 'value', type: 'value',
name: '数量',
/* 纵轴:end = 坐标轴最大值一端,即竖轴上方 */
nameLocation: 'end',
/* 在默认基础上再向左约 20px:nameGap 为轴标题与轴线间距(左侧 Y 轴顶端时增大则标题更靠左) */
nameGap: 20,
nameRotate: 0,
nameTextStyle: {
color: 'rgba(170, 173, 177, 1)',
fontFamily: 'Source Han Sans CN',
fontWeight: 400,
fontSize: 14,
lineHeight: 22,
letterSpacing: 0,
align: 'right',
verticalAlign: 'bottom'
},
splitLine: { splitLine: {
show: true, // 显示网格线 show: true, // 显示网格线
lineStyle: { lineStyle: {
......
...@@ -10,7 +10,8 @@ const getPieChart = (data) => { ...@@ -10,7 +10,8 @@ const getPieChart = (data) => {
// left: '15%', // left: '15%',
// containLabel: true // containLabel: true
// }, // },
radius: [70, 100], // 使用百分比半径,避免固定像素导致饼图“看起来没铺满容器”
radius: ['45%', '60%'],
height: '100%', height: '100%',
left: 'center', left: 'center',
width: '100%', width: '100%',
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论