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

feat:核心成员

上级 58537cbd
VITE_BASE_API= '/api'
\ No newline at end of file
VITE_BASE_API= '/api'
# 图表解读等 /aiAnalysis 代理服务密钥(勿提交到公开仓库时可改为本地 .env.local)
VITE_AI_ANALYSIS_API_KEY=aircasKEY19491001
\ No newline at end of file
# 线上地址
VITE_BASE_API= '/api'
\ No newline at end of file
VITE_BASE_API= '/api'
# 图表解读等 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) {
return request({
method: 'POST',
url: `/aiAnalysis/chart_interpretation`,
data,
})
}
\ No newline at end of file
export async function getChartAnalysis(data, options = {}) {
const { onInterpretationDelta } = options;
return fetchChartInterpretation(data, onInterpretationDelta);
}
......@@ -51,6 +51,14 @@ service.interceptors.request.use(config => {
config.headers['token'] = token
// 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
}, error => {
console.log(error)
......@@ -82,8 +90,14 @@ service.interceptors.response.use(
// 重复请求触发的取消不提示错误
if (isCanceledError) return Promise.reject(error)
// 处理token过期或无效的情况
if (error.response && (error.response.status === 401 || error.response.status === 403)) {
// 处理token过期或无效的情况(排除 AI 分析服务:其 401 多为 API Key 问题)
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',
......
......@@ -35,13 +35,19 @@ export function getThinkTankRiskSignal() {
})
}
// 政策建议趋势分布
/**
* 政策建议趋势分布(数量变化趋势)
* @param {{ startDate: string, endDate: string }} params - 如 2024-01-01 ~ 2024-12-31
*/
export function getThinkTankPolicyIndustryChange(params) {
return request({
method: 'GET',
url: `/api/thinkTankOverview/policyIndustryChange/${params}`,
})
method: "GET",
url: `/api/thinkTankOverview/policyIndustryChange`,
params: {
startDate: params.startDate,
endDate: params.endDate
}
});
}
// 政策建议领域分布
......@@ -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) {
return request({
method: 'GET',
url: `/api/thinkTankInfo/policy/${params.id}/${params.startDate}`,
url: '/api/thinkTankInfo/policy',
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) {
return request({
......
......@@ -24,17 +24,19 @@ const props = defineProps({
</script>
<style lang="scss" scoped>
.tip-wrapper{
.tip-wrapper {
width: 100%;
display: flex;
gap: 8px;
justify-content: center;
align-items: center;
height: 22px;
.icon{
.icon {
width: 16px;
height: 16px;
img{
img {
width: 100%;
height: 100%;
}
......
......@@ -9,6 +9,10 @@ const setChart = (option, chartId) => {
chartDom.removeAttribute("_echarts_instance_");
let chart = echarts.init(chartDom);
chart.setOption(option);
// 容器可能受布局/异步渲染影响,强制一次 resize 保证 canvas 与容器一致
setTimeout(() => {
chart.resize();
}, 0);
return chart;
};
......
......@@ -6,21 +6,35 @@
<div class="icon">
<img src="./images/box-header-icon1.png" alt="" />
</div>
<div class="title">{{ "政策建议领域分布" }}</div>
<div class="title">{{ "领域分布情况" }}</div>
<!-- <div class="box-header-right">{{ "查看数据源 >" }}</div> -->
<div class="select-box">
<el-select v-model="box1SelectYear" placeholder="选择时间" style="width: 100px">
<el-option v-for="(item, index) in box1YearList" :key="index" :label="item.label + '年'"
:value="item.value" @click="handleGetThinkPolicyIndustry()" />
<el-select v-model="box1SelectYear" placeholder="选择时间" style="width: 100px"
@change="handleGetThinkPolicyIndustry">
<el-option v-for="item in box1YearList" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</div>
</div>
<div class="box-main">
<div id="box1Chart"></div>
<div class="source">
<div class="info"><img src="./images/image-exclamation.png"></div>
<div class="text"> 数据来源:美国国会官网,数据时间:2015.1至2025.12</div>
</div>
<div class="box-main" :class="{ 'chart-main--empty': !hasBox1ChartData }">
<template v-if="!hasBox1ChartData">
<el-empty class="policy-tracking-el-empty" description="暂无数据" :image-size="100" />
</template>
<template v-else>
<div id="box1Chart"></div>
<div class="source">
<TipTab :text="POLICY_TRACKING_TIP_BOX1" />
</div>
<div class="chart-box">
<div class="btn-box" v-if="!isShowAiContentPolicyPt1"
@mouseenter="handleSwitchAiContentShowPolicyPt1(true)">
<AiButton />
</div>
<div class="content-box" v-if="isShowAiContentPolicyPt1"
@mouseleave="handleSwitchAiContentShowPolicyPt1(false)">
<AiPane :aiContent="aiContentPolicyPt1" />
</div>
</div>
</template>
</div>
</div>
<div class="box1 box">
......@@ -28,21 +42,35 @@
<div class="icon">
<img src="./images/box-header-icon2.png" alt="" />
</div>
<div class="title">{{ "政策建议涉及部门分布" }}</div>
<div class="title">{{ "部门分布情况" }}</div>
<!-- <div class="box-header-right">{{ "查看数据源 >" }}</div> -->
<div class="select-box">
<el-select v-model="box2SelectYear" placeholder="选择时间" style="width: 100px">
<el-option v-for="(item, index) in box2YearList" :key="index" :label="item.label + '年'"
:value="item.value" @click="handleGetPolicyAdviceDeptDistribution()" />
<el-select v-model="box2SelectYear" placeholder="选择时间" style="width: 100px"
@change="handleGetPolicyAdviceDeptDistribution">
<el-option v-for="item in box2YearList" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</div>
</div>
<div class="box-main">
<div id="box2Chart"></div>
<div class="source">
<div class="info"><img src="./images/image-exclamation.png"></div>
<div class="text"> 数据来源:美国国会官网,数据时间:2015.1至2025.12</div>
</div>
<div class="box-main" :class="{ 'chart-main--empty': !hasBox2ChartData }">
<template v-if="!hasBox2ChartData">
<el-empty class="policy-tracking-el-empty" description="暂无数据" :image-size="100" />
</template>
<template v-else>
<div id="box2Chart"></div>
<div class="source">
<TipTab :text="POLICY_TRACKING_TIP_BOX2" />
</div>
<div class="chart-box">
<div class="btn-box" v-if="!isShowAiContentPolicyPt2"
@mouseenter="handleSwitchAiContentShowPolicyPt2(true)">
<AiButton />
</div>
<div class="content-box" v-if="isShowAiContentPolicyPt2"
@mouseleave="handleSwitchAiContentShowPolicyPt2(false)">
<AiPane :aiContent="aiContentPolicyPt2" />
</div>
</div>
</template>
</div>
</div>
<!-- <div class="box2 box">
......@@ -61,7 +89,7 @@
</el-select>
</div>
<div id="box2Chart">
<div class="box2-item" v-for="(item, index) in box2Data" :key="index">
<div class="box2-item" v-for="(item, index) in box2TotalData" :key="index">
<div class="icon" :style="{ background: item.color }"></div>
<div class="name">{{ item.industry }}</div>
<el-progress :show-text="false" :color="box2ColorList[index]" style="width: 170px" stroke-width="8"
......@@ -77,38 +105,60 @@
<div class="icon">
<img src="./images/box-header-icon2.png" alt="" />
</div>
<div class="title">{{ "热门研究方向变化趋势" }}</div>
<div class="title">{{ "研究领域变化趋势" }}</div>
<!-- <div class="box-header-right">{{ "查看数据源 >" }}</div> -->
<div class="select-box">
<el-select v-model="box3SelectMonths" placeholder="选择时间" style="width: 100px">
<el-option v-for="item in box3MonthsList" :key="item.value" :label="item.label + '年'" :value="item.value"
@click="handleGetThinkPolicyIndustryChange()" />
<el-select v-model="box3SelectMonths" placeholder="选择时间" style="width: 100px"
@change="handleGetThinkPolicyIndustryChange">
<el-option v-for="item in box3MonthsList" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</div>
</div>
<div class="box3-main">
<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 class="box3-main" :class="{ 'chart-main--empty': !hasBox3ChartData }">
<template v-if="!hasBox3ChartData">
<el-empty class="policy-tracking-el-empty" description="暂无数据" :image-size="100" />
</template>
<template v-else>
<div class="box3-chart-row">
<button v-if="box3LegendShowNav" type="button" class="box3-legend-nav-btn" :disabled="!box3LegendCanPrev"
aria-label="上一页图例" @click="handleBox3LegendPrev">
</button>
<div id="box3Chart" class="box3-chart-canvas"></div>
<button v-if="box3LegendShowNav" type="button" class="box3-legend-nav-btn" :disabled="!box3LegendCanNext"
aria-label="下一页图例" @click="handleBox3LegendNext">
</button>
</div>
<div class="source">
<TipTab :text="POLICY_TRACKING_TIP_BOX3" />
</div>
<div class="chart-box">
<div class="btn-box" v-if="!isShowAiContentPolicyPt3"
@mouseenter="handleSwitchAiContentShowPolicyPt3(true)">
<AiButton />
</div>
<div class="content-box" v-if="isShowAiContentPolicyPt3"
@mouseleave="handleSwitchAiContentShowPolicyPt3(false)">
<AiPane :aiContent="aiContentPolicyPt3" />
</div>
</div>
</template>
</div>
</div>
</div>
<div class="bottom">
<div class="bottom-header">
<div class="search-box">
<div class="search-box" @keyup.enter="handleGetThinkPolicy()">
<el-input placeholder="搜索政策建议" v-model="searchPolicy" style="width: 270px" />
<div class="icon">
<img src="./images/search-icon.png" alt="" />
<img src="./images/search-icon.png" alt="" @click="handleGetThinkPolicy()" />
</div>
</div>
<div class="select-box">
<el-select v-model="selectedYear" placeholder="选择时间" style="width: 120px" @click="handleGetThinkPolicy()">
<el-option v-for="item in yearList" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-select class="select-box-sort" v-model="sort" placeholder="倒序" style="width: 120px" :teleported="true"
<el-select class="select-box-sort" v-model="sort" placeholder="发布时间" style="width: 120px" :teleported="true"
:placement="'bottom-start'" :popper-options="{
modifiers: [
{
......@@ -122,7 +172,7 @@
]
}">
<template #prefix>
<img v-if="!sort" src="../thinkDynamics/images/image down.png" class="select-prefix-img" alt="" />
<img v-if="sort !== true" src="../thinkDynamics/images/image down.png" class="select-prefix-img" alt="" />
<img v-else src="../thinkDynamics/images/image up.png" class="select-prefix-img" alt="" />
</template>
<el-option @click="handleGetThinkPolicy()" :key="true" label="正序" :value="true" />
......@@ -138,17 +188,44 @@
<div class="title">{{ "科技领域" }}</div>
</div>
<div class="select-main">
<div class="checkbox-group">
<!-- <el-checkbox v-for="(item, index) in areaList" :key="index" v-model="selectedAreaList" :label="item"
class="filter-checkbox">
{{ item }}
</el-checkbox> -->
<el-checkbox class="filter-checkbox" label="全部领域"></el-checkbox>
<el-checkbox class="filter-checkbox" v-for="research in areaList" :key="research.id"
v-model="selectedAreaList" :label="research.id">
<el-checkbox-group
class="checkbox-group"
v-model="selectedAreaList"
@change="handleChangePolicyAreaList">
<el-checkbox class="filter-checkbox" :label="POLICY_FILTER_ALL_AREA">
{{ POLICY_FILTER_ALL_AREA }}
</el-checkbox>
<el-checkbox
class="filter-checkbox"
v-for="research in areaList"
:key="research.id"
:label="research.id">
{{ research.name }}
</el-checkbox>
</div>
</el-checkbox-group>
</div>
</div>
<div class="select-box-department">
<div class="select-box-header">
<div class="icon"></div>
<div class="title">{{ "涉及部门" }}</div>
</div>
<div class="select-main">
<el-checkbox-group
class="checkbox-group"
v-model="selectedDepartmentList"
@change="handleChangePolicyDepartmentList">
<el-checkbox class="filter-checkbox" :label="POLICY_FILTER_ALL_DEPT">
{{ POLICY_FILTER_ALL_DEPT }}
</el-checkbox>
<el-checkbox
class="filter-checkbox"
v-for="department in selectableDepartment"
:key="department.id"
:label="department.id">
{{ department.name }}
</el-checkbox>
</el-checkbox-group>
</div>
</div>
<div class="select-box-publish">
......@@ -157,49 +234,58 @@
<div class="title">{{ "发布时间" }}</div>
</div>
<div class="select-main">
<div class="checkbox-group">
<!-- <el-checkbox v-for="(item, index) in areaList" :key="index" v-model="selectedAreaList" :label="item"
class="filter-checkbox">
{{ item }}
</el-checkbox> -->
<el-checkbox class="filter-checkbox" label="全部领域"></el-checkbox>
<el-checkbox class="filter-checkbox" v-for="year in selectableYears" :key="year"
v-model="selectedAreaList" :label="year">
<el-checkbox-group
class="checkbox-group"
v-model="selectedYearList"
@change="handleChangePolicyYearList">
<el-checkbox class="filter-checkbox" :label="POLICY_FILTER_ALL_TIME">
{{ POLICY_FILTER_ALL_TIME }}
</el-checkbox>
<el-checkbox
class="filter-checkbox"
v-for="year in selectableYears"
:key="year"
:label="year">
{{ year }}
</el-checkbox>
</div>
</el-checkbox-group>
</div>
</div>
</div>
<div class="right">
<div class="right-main">
<div class="right-main-item" v-for="(item, index) in policyList" :key="index">
<div class="right-main-item" v-for="item in policyList" :key="item.id">
<div class="item-left">
<img :src="item.imageUrl" alt="" />
</div>
<div class="item-right">
<div>
<div class="title">{{ item.content }}</div>
<div class="title">{{ item.titleZh }}</div>
<div class="info">
{{ item.times }} · {{ item.name }}
<div class="text">
{{ item.reportDateDisplay }}·{{ item.contentZh }}
</div>
<div class="more" @click="toDetail(item)">
<img src="@/views/thinkTank/ThinkTankDetail/thinkDynamics/images/image open.png" alt="" />
</div>
</div>
<div class="tag-box">
<AreaTag v-for="(tag, idx) in item.tagList" :key="idx" :tagName="tag"></AreaTag>
</div>
<div class="file-box">
<div class="file" v-for="(file, idxx) in item.relationBillsList" :key="idxx">
<div class="file" v-for="(file, idxx) in item.relatedBills" :key="`${file.id}-${idxx}`">
<div class="type">法案</div>
<div class="title">{{ file.billName }}</div>
<div class="title">{{ file.name }}</div>
<div class="more">
<img src="./images/arrow-right.png" alt="" />
</div>
</div>
<div class="file" v-for="(file, idxx) in item.relationAdList" :key="idxx">
<div class="file" v-for="(file, idxx) in item.relatedAdministrativeOrders"
:key="file.bcId != null ? String(file.bcId) + '-' + idxx : idxx">
<div class="type">政令</div>
<div class="title">{{ file.adName }}</div>
<div class="title">{{ file.content }}</div>
<div class="more">
<img src="./images/arrow-right.png" alt="" />
</div>
......@@ -213,7 +299,7 @@
<div class="right-footer">
<div class="info">共{{ total }}条政策建议</div>
<div class="page-box">
<el-pagination :page-size="12" background layout="prev, pager, next" :total="total"
<el-pagination :page-size="10" background layout="prev, pager, next" :total="total"
@current-change="handleCurrentChange" :current-page="currentPage" />
</div>
</div>
......@@ -224,22 +310,71 @@
</template>
<script setup>
import { ref, watch, onMounted } from "vue";
import { ref, watch, onMounted, computed, nextTick } from "vue";
import setChart from "@/utils/setChart";
import getPieChart from "./utils/piechart";
import getMultiLineChart from "./utils/multiLineChart";
import getMultiLineChart, { POLICY_TRACKING_LEGEND_PAGE_SIZE } from "./utils/multiLineChart";
import {
getThinkPolicyIndustry,
getThinkPolicyIndustryTotal,
getThinkPolicyIndustryChange,
getHylyList,
getThinkPolicy,
getPolicyAdviceDeptDistribution
getPolicyAdviceDeptDistribution,
getGovAgencyDict
} from "@/api/thinkTank/overview";
import { getChartAnalysis } from "@/api/aiAnalysis/index";
import AiButton from "@/components/base/Ai/AiButton/index.vue";
import AiPane from "@/components/base/Ai/AiPane/index.vue";
import TipTab from "@/views/thinkTank/TipTab/index.vue";
import defaultNewsIcon from "@/assets/icons/default-icon-news.png";
import { useRouter } from "vue-router";
const router = useRouter();
/** 与智库概览 TipTab 文案格式一致(政策追踪-美国国会) */
const POLICY_TRACKING_TIP_BOX1 =
"智库报告中政策建议的领域分布情况,数据来源:美国兰德公司官网";
const POLICY_TRACKING_TIP_BOX2 =
"智库报告中政策建议涉及领域分布情况,数据来源:美国兰德公司官网";
const POLICY_TRACKING_TIP_BOX3 =
"智库报告热门研究领域变化趋势,数据来源:美国兰德公司官网";
/** 筛选「全部」项文案,与市场准入概览-资源库复选逻辑一致 */
const POLICY_FILTER_ALL_AREA = "全部领域";
const POLICY_FILTER_ALL_TIME = "全部时间";
const POLICY_FILTER_ALL_DEPT = "全部部门";
const isShowAiContentPolicyPt1 = ref(false);
const aiContentPolicyPt1 = ref("");
const isPolicyPt1InterpretLoading = ref(false);
const handleSwitchAiContentShowPolicyPt1 = (val) => {
isShowAiContentPolicyPt1.value = val;
if (val) {
fetchPolicyPtBox1ChartInterpretation();
}
};
const isShowAiContentPolicyPt2 = ref(false);
const aiContentPolicyPt2 = ref("");
const isPolicyPt2InterpretLoading = ref(false);
const handleSwitchAiContentShowPolicyPt2 = (val) => {
isShowAiContentPolicyPt2.value = val;
if (val) {
fetchPolicyPtBox2ChartInterpretation();
}
};
const isShowAiContentPolicyPt3 = ref(false);
const aiContentPolicyPt3 = ref("");
const isPolicyPt3InterpretLoading = ref(false);
const handleSwitchAiContentShowPolicyPt3 = (val) => {
isShowAiContentPolicyPt3.value = val;
if (val) {
fetchPolicyPtBox3ChartInterpretation();
}
};
// import Img1 from "./images/img1.png";
// import Img2 from "./images/img2.png";
// import Img3 from "./images/img3.png";
......@@ -284,11 +419,18 @@ const box1Data = ref([
// }
]);
const relationBillsList = ref([{ billName: "2025《人工智能安全与评估法案》" }])
const relationAdList = ref([{ adName: "2025《人工智能安全与评估法案》" }])
/** 与智库首页数量变化趋势一致:有数据才展示图表与数据来源 */
const hasBox1ChartData = computed(
() => Array.isArray(box1Data.value) && box1Data.value.length > 0
);
const box1SelectYear = ref("2025");
const box1YearList = ref([
{
label: "2026",
value: "2026"
},
{
label: "2025",
value: "2025"
......@@ -300,10 +442,16 @@ const box1YearList = ref([
{
label: "2023",
value: "2023"
}
},
{
label: "2022",
value: "2022"
},
]);
const selectableYears = ref(["2025年", "2024年", "2023年", "2022年", "2021年", "更早"]);
const selectableDepartment = ref([]);
const handleGetThinkPolicyIndustry = async () => {
aiContentPolicyPt1.value = "";
try {
const parmas = {
id: router.currentRoute._value.params.id,
......@@ -313,21 +461,41 @@ const handleGetThinkPolicyIndustry = async () => {
console.log("提出建议领域分布", res);
if (res.code === 200 && res.data) {
const list = Array.isArray(res.data) ? res.data.slice(0, 7) : [];
if (!list.length) {
box1Data.value = [];
return;
}
const data = list.map(item => ({
name: item.industry,
value: item.amount,
percent: item.percent
}));
box1Data.value = data;
/* v-if 有数据后才挂载 #box1Chart,须等 DOM 更新后再 init echarts */
await nextTick();
const box1Chart = getPieChart(box1Data.value);
setChart(box1Chart, "box1Chart");
} else {
box1Data.value = [];
}
} catch (error) {
console.error("获取提出建议领域分布error", error);
box1Data.value = [];
}
};
// 政策建议涉及部门分布(饼图)
const box2Data = ref([]);
const hasBox2ChartData = computed(
() => Array.isArray(box2Data.value) && box2Data.value.length > 0
);
/** 仅「相关政策领域分布」注释区块用;勿写入 box2Data,否则与部门分布接口并行会覆盖饼图数据导致空白 */
const box2TotalData = ref([]);
const handleGetPolicyAdviceDeptDistribution = async () => {
aiContentPolicyPt2.value = "";
try {
const parmas = {
id: router.currentRoute._value.params.id,
......@@ -335,7 +503,12 @@ const handleGetPolicyAdviceDeptDistribution = async () => {
};
const res = await getPolicyAdviceDeptDistribution(parmas);
console.log("政策建议涉及部门分布", res);
if (res.code === 200 && res.data && Array.isArray(res.data.series)) {
if (
res.code === 200 &&
res.data &&
Array.isArray(res.data.series) &&
res.data.series.length > 0
) {
// 接口新结构:data.series 为 [{ name, value, percent, extra }]
const list = res.data.series.slice(0, 7);
box2Data.value = list.map(item => ({
......@@ -344,19 +517,26 @@ const handleGetPolicyAdviceDeptDistribution = async () => {
percent: item.percent
}));
await nextTick();
const box2Chart = getPieChart(box2Data.value);
setChart(box2Chart, "box2Chart");
} else {
box2Data.value = [];
}
} catch (error) {
console.error("获取政策建议涉及部门分布 error", error);
box2Data.value = [];
}
};
// 相关政策领域分布
const box2Data = ref([]);
// 相关政策领域分布(旧模块用 box2Data,勿与部门分布混用)
const box2ColorList = ref(["#CE4F51", "#055FC2", "#13A8A8", "#722ED1", "#FA8C16", "#69B1FF"]);
const box2SelectYear = ref("2025");
const box2YearList = ref([
{
label: "2026",
value: "2026"
},
{
label: "2025",
value: "2025"
......@@ -368,7 +548,11 @@ const box2YearList = ref([
{
label: "2023",
value: "2023"
}
},
{
label: "2022",
value: "2022"
},
]);
const handleGetThinkPolicyIndustryTotal = async () => {
......@@ -380,30 +564,107 @@ const handleGetThinkPolicyIndustryTotal = async () => {
const res = await getThinkPolicyIndustryTotal(parmas);
console.log("相关政策领域分布", res);
if (res.code === 200 && res.data) {
box2Data.value = res.data;
box2TotalData.value = Array.isArray(res.data) ? res.data : [];
} else {
box2TotalData.value = [];
}
} catch (error) {
console.error("获取相关政策领域分布error", error);
box2TotalData.value = [];
}
};
// 热门研究方向变化趋势
const box3Data = ref({
// title: ["2024-Q1", "2024-Q2", "2024-Q3", "2024-Q4"],
// data: [
// {
// name: "新能源",
// value: [1,3,4,6]
// },
// {
// name: "人工智能",
// value: [1,3,4,2]
// },
// ...
// ]
title: [],
data: []
});
const hasBox3ChartData = computed(() => {
const v = box3Data.value;
if (!v || typeof v !== "object") {
return false;
}
const title = v.title;
const data = v.data;
return (
Array.isArray(title) &&
title.length > 0 &&
Array.isArray(data) &&
data.length > 0
);
});
const box3LegendPageIndex = ref(0);
const box3LegendPageCount = computed(() => {
const n = box3Data.value?.data?.length ?? 0;
return Math.max(1, Math.ceil(n / POLICY_TRACKING_LEGEND_PAGE_SIZE));
});
const box3LegendShowNav = computed(() => {
const n = box3Data.value?.data?.length ?? 0;
return n > POLICY_TRACKING_LEGEND_PAGE_SIZE;
});
const box3LegendCanPrev = computed(() => box3LegendPageIndex.value > 0);
const box3LegendCanNext = computed(
() => box3LegendPageIndex.value < box3LegendPageCount.value - 1
);
const handleBox3LegendPrev = () => {
if (box3LegendPageIndex.value <= 0) {
return;
}
box3LegendPageIndex.value -= 1;
renderBox3Chart();
};
const handleBox3LegendNext = () => {
if (box3LegendPageIndex.value >= box3LegendPageCount.value - 1) {
return;
}
box3LegendPageIndex.value += 1;
renderBox3Chart();
};
/** 研究领域变化趋势:图例分页与概览「数量变化趋势」一致 */
const renderBox3Chart = async () => {
const v = box3Data.value;
if (
!v ||
!Array.isArray(v.title) ||
v.title.length === 0 ||
!Array.isArray(v.data) ||
v.data.length === 0
) {
return;
}
const totalNames = v.data.length;
const pages = Math.max(1, Math.ceil(totalNames / POLICY_TRACKING_LEGEND_PAGE_SIZE));
if (box3LegendPageIndex.value >= pages) {
box3LegendPageIndex.value = pages - 1;
}
if (box3LegendPageIndex.value < 0) {
box3LegendPageIndex.value = 0;
}
await nextTick();
const box3Chart = getMultiLineChart(
{ title: v.title, data: v.data },
{
legendShowCount: POLICY_TRACKING_LEGEND_PAGE_SIZE,
legendPageIndex: box3LegendPageIndex.value
}
);
setChart(box3Chart, "box3Chart");
};
const box3SelectMonths = ref("2025");
const box3MonthsList = ref([
{
label: "2026",
value: "2026"
},
{
label: "2025",
value: "2025"
......@@ -415,10 +676,15 @@ const box3MonthsList = ref([
{
label: "2023",
value: "2023"
}
},
{
label: "2022",
value: "2022"
},
]);
const handleGetThinkPolicyIndustryChange = async () => {
aiContentPolicyPt3.value = "";
try {
const parmas = {
id: router.currentRoute._value.params.id,
......@@ -426,7 +692,7 @@ const handleGetThinkPolicyIndustryChange = async () => {
};
const res = await getThinkPolicyIndustryChange(parmas);
console.log("热门研究方向变化趋势", res);
if (res.code === 200 && res.data) {
if (res.code === 200 && Array.isArray(res.data) && res.data.length > 0) {
// 提取所有季度信息
const quarters = res.data.map(item => item.yearQuarter);
quarters.sort(); // 按时间排序
......@@ -434,9 +700,11 @@ const handleGetThinkPolicyIndustryChange = async () => {
// 提取所有行业名称
const industries = new Set();
res.data.forEach(item => {
item.industryList.forEach(industry => {
industries.add(industry.industry);
});
if (Array.isArray(item.industryList)) {
item.industryList.forEach(industry => {
industries.add(industry.industry);
});
}
});
// 构建前端数据结构
......@@ -449,24 +717,175 @@ const handleGetThinkPolicyIndustryChange = async () => {
name: industry,
value: quarters.map(quarter => {
const quarterData = res.data.find(q => q.yearQuarter === quarter);
const industryAmount = quarterData?.industryList.find(i => i.industry === industry)?.amount || 0;
const industryAmount =
quarterData?.industryList?.find(i => i.industry === industry)?.amount || 0;
return industryAmount;
})
};
frontendData.data.push(industryData);
});
if (!frontendData.data.length) {
box3Data.value = { title: [], data: [] };
return;
}
box3Data.value = frontendData;
// 传入全部行业数据,支持 13 条线同时展示
const box3Chart = getMultiLineChart(
box3Data.value.title,
box3Data.value.data
);
setChart(box3Chart, "box3Chart");
box3LegendPageIndex.value = 0;
await renderBox3Chart();
} else {
box3Data.value = { title: [], data: [] };
}
} catch (error) {
console.error("获取热门研究方向变化趋势error", error);
box3Data.value = { title: [], data: [] };
}
};
/** 政策追踪-领域分布饼图解读(与概览页 box6 入参一致) */
const fetchPolicyPtBox1ChartInterpretation = async () => {
const list = Array.isArray(box1Data.value) ? box1Data.value : [];
if (!list.length) {
aiContentPolicyPt1.value = "暂无图表数据";
return;
}
const hasValidContent =
aiContentPolicyPt1.value &&
aiContentPolicyPt1.value !== "解读生成中…" &&
aiContentPolicyPt1.value !== "解读加载失败" &&
aiContentPolicyPt1.value !== "暂无图表数据";
if (hasValidContent || isPolicyPt1InterpretLoading.value) {
return;
}
isPolicyPt1InterpretLoading.value = true;
aiContentPolicyPt1.value = "解读生成中…";
const chartPayload = {
type: "饼图",
name: "领域分布情况",
year: box1SelectYear.value,
data: list.map((item) => ({
name: item.name,
value: item.value
}))
};
try {
const text = await getChartAnalysis(
{ text: JSON.stringify(chartPayload) },
{
onInterpretationDelta: (chunk) => {
if (chunk) {
aiContentPolicyPt1.value = chunk;
}
}
}
);
aiContentPolicyPt1.value = text || "未返回有效解读内容";
} catch (error) {
console.error("政策追踪领域分布图表解读请求失败", error);
aiContentPolicyPt1.value = "解读加载失败";
} finally {
isPolicyPt1InterpretLoading.value = false;
}
};
/** 政策追踪-部门分布饼图解读 */
const fetchPolicyPtBox2ChartInterpretation = async () => {
const list = Array.isArray(box2Data.value) ? box2Data.value : [];
if (!list.length) {
aiContentPolicyPt2.value = "暂无图表数据";
return;
}
const hasValidContent =
aiContentPolicyPt2.value &&
aiContentPolicyPt2.value !== "解读生成中…" &&
aiContentPolicyPt2.value !== "解读加载失败" &&
aiContentPolicyPt2.value !== "暂无图表数据";
if (hasValidContent || isPolicyPt2InterpretLoading.value) {
return;
}
isPolicyPt2InterpretLoading.value = true;
aiContentPolicyPt2.value = "解读生成中…";
const chartPayload = {
type: "饼图",
name: "部门分布情况",
year: box2SelectYear.value,
data: list.map((item) => ({
name: item.name,
value: item.value
}))
};
try {
const text = await getChartAnalysis(
{ text: JSON.stringify(chartPayload) },
{
onInterpretationDelta: (chunk) => {
if (chunk) {
aiContentPolicyPt2.value = chunk;
}
}
}
);
aiContentPolicyPt2.value = text || "未返回有效解读内容";
} catch (error) {
console.error("政策追踪部门分布图表解读请求失败", error);
aiContentPolicyPt2.value = "解读加载失败";
} finally {
isPolicyPt2InterpretLoading.value = false;
}
};
/** 政策追踪-研究领域变化趋势折线图解读(与概览页 box5 结构一致) */
const fetchPolicyPtBox3ChartInterpretation = async () => {
const v = box3Data.value;
if (
!v ||
!Array.isArray(v.title) ||
v.title.length === 0 ||
!Array.isArray(v.data) ||
v.data.length === 0
) {
aiContentPolicyPt3.value = "暂无图表数据";
return;
}
const hasValidContent =
aiContentPolicyPt3.value &&
aiContentPolicyPt3.value !== "解读生成中…" &&
aiContentPolicyPt3.value !== "解读加载失败" &&
aiContentPolicyPt3.value !== "暂无图表数据";
if (hasValidContent || isPolicyPt3InterpretLoading.value) {
return;
}
isPolicyPt3InterpretLoading.value = true;
aiContentPolicyPt3.value = "解读生成中…";
const chartPayload = {
type: "折线图",
name: "研究领域变化趋势",
data: v.title.map((label, i) => {
const point = { period: label };
v.data.forEach((s) => {
point[s.name] = s.value[i] ?? 0;
});
return point;
})
};
try {
const text = await getChartAnalysis(
{ text: JSON.stringify(chartPayload) },
{
onInterpretationDelta: (chunk) => {
if (chunk) {
aiContentPolicyPt3.value = chunk;
}
}
}
);
aiContentPolicyPt3.value = text || "未返回有效解读内容";
} catch (error) {
console.error("政策追踪研究领域趋势图表解读请求失败", error);
aiContentPolicyPt3.value = "解读加载失败";
} finally {
isPolicyPt3InterpretLoading.value = false;
}
};
// 搜索政策建议
const searchPolicy = ref("");
......@@ -487,9 +906,11 @@ const yearList = ref([
const selectedYear = ref(1);
// 科技领域
// 科技领域 / 涉及部门 / 发布时间(含「全部」项,与市场准入概览资源库互斥逻辑一致)
const areaList = ref([]);
const selectedAreaList = ref([]);
const selectedDepartmentList = ref([POLICY_FILTER_ALL_DEPT]);
const selectedAreaList = ref([POLICY_FILTER_ALL_AREA]);
const selectedYearList = ref([POLICY_FILTER_ALL_TIME]);
//获取科技领域
const handleGetHylyList = async () => {
......@@ -498,11 +919,77 @@ const handleGetHylyList = async () => {
console.log("智库研究类型信息", res.data);
if (res.code === 200 && res.data) {
areaList.value = res.data;
console.log(areaList.value)
}
} catch (error) {
console.error("获取研究类型error", error);
}
};
/** 涉及部门:GET /api/commonDict/gov/agency */
const handleGetGovAgencyList = async () => {
try {
const res = await getGovAgencyDict();
console.log(res, " 涉及部门")
if (res.code === 200 && Array.isArray(res.data)) {
selectableDepartment.value = res.data.map((row) => ({
id: String(row.id),
name: row.name
}));
} else {
selectableDepartment.value = [];
}
} catch (error) {
console.error("获取政府机构字典 error", error);
selectableDepartment.value = [];
}
};
/** 科技领域:与「全部领域」互斥;空选时回到「全部领域」 */
const handleChangePolicyAreaList = (val) => {
if (val.includes(POLICY_FILTER_ALL_AREA) && val.length > 1) {
if (val[val.length - 1] === POLICY_FILTER_ALL_AREA) {
selectedAreaList.value = [POLICY_FILTER_ALL_AREA];
} else {
selectedAreaList.value = val.filter((item) => item !== POLICY_FILTER_ALL_AREA);
}
} else if (val.length === 0) {
selectedAreaList.value = [POLICY_FILTER_ALL_AREA];
}
currentPage.value = 1;
handleGetThinkPolicy();
};
/** 涉及部门:与「全部部门」互斥;空选时回到「全部部门」 */
const handleChangePolicyDepartmentList = (val) => {
if (val.includes(POLICY_FILTER_ALL_DEPT) && val.length > 1) {
if (val[val.length - 1] === POLICY_FILTER_ALL_DEPT) {
selectedDepartmentList.value = [POLICY_FILTER_ALL_DEPT];
} else {
selectedDepartmentList.value = val.filter((item) => item !== POLICY_FILTER_ALL_DEPT);
}
} else if (val.length === 0) {
selectedDepartmentList.value = [POLICY_FILTER_ALL_DEPT];
}
currentPage.value = 1;
handleGetThinkPolicy();
};
/** 发布时间:与「全部时间」互斥;空选时回到「全部时间」 */
const handleChangePolicyYearList = (val) => {
if (val.includes(POLICY_FILTER_ALL_TIME) && val.length > 1) {
if (val[val.length - 1] === POLICY_FILTER_ALL_TIME) {
selectedYearList.value = [POLICY_FILTER_ALL_TIME];
} else {
selectedYearList.value = val.filter((item) => item !== POLICY_FILTER_ALL_TIME);
}
} else if (val.length === 0) {
selectedYearList.value = [POLICY_FILTER_ALL_TIME];
}
currentPage.value = 1;
handleGetThinkPolicy();
};
const policyList = ref([
// {
// img: Img1,
......@@ -583,8 +1070,119 @@ function getDateMonthsAgo(months) {
return `${year}-${month}-${day}`;
}
/** 当天 YYYY-MM-DD,作政策列表 endDate */
function getTodayYmd() {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
/** 「更早」对应自然年区间下界(含) */
const POLICY_YEAR_EARLIER_START = 2000;
/** 「更早」对应自然年上界(含) */
const POLICY_YEAR_EARLIER_END = 2020;
const POLICY_YEAR_LABEL_RE = /^(\d{4})年$/;
/** 勾选「全部时间」时的固定起止(结束日按产品要求写死) */
const POLICY_ALL_TIME_START_DATE = "2000-01-01";
const POLICY_ALL_TIME_END_DATE = "2025-12-31";
/**
* 根据发布时间多选(如「2025年」「更早」)推导列表查询 startDate / endDate
* - 仅选「2025年」→ 2025-01-01 ~ 2025-12-31
* - 多选多个自然年 → 取最小年 01-01 与最大年 12-31 的包络
* - 「更早」→ 2000-01-01 ~ 2020-12-31,可与具体年份合并为并集区间
* - 仅「全部时间」→ 2000-01-01 ~ 2025-12-31(写死)
* - 未选任何项(无「全部时间」)→ 与顶部「近一年/两年/三年」一致
*/
function getPolicyListDateRangeFromYearList(labels, relativeYearYears) {
const raw = Array.isArray(labels) ? labels.filter(Boolean) : [];
const hasAllTime = raw.includes(POLICY_FILTER_ALL_TIME);
const set = new Set(raw);
set.delete(POLICY_FILTER_ALL_TIME);
if (set.size === 0) {
if (hasAllTime) {
return { startDate: POLICY_ALL_TIME_START_DATE, endDate: POLICY_ALL_TIME_END_DATE };
}
const years = Number(relativeYearYears) > 0 ? Number(relativeYearYears) : 1;
return { startDate: getDateYearsAgo(years), endDate: getTodayYmd() };
}
let minY = null;
let maxY = null;
if (set.has("更早")) {
minY = POLICY_YEAR_EARLIER_START;
maxY = POLICY_YEAR_EARLIER_END;
}
for (const lab of set) {
const m = String(lab).match(POLICY_YEAR_LABEL_RE);
if (!m) {
continue;
}
const y = Number(m[1]);
minY = minY == null ? y : Math.min(minY, y);
maxY = maxY == null ? y : Math.max(maxY, y);
}
if (minY == null || maxY == null) {
const years = Number(relativeYearYears) > 0 ? Number(relativeYearYears) : 1;
return { startDate: getDateYearsAgo(years), endDate: getTodayYmd() };
}
return {
startDate: `${minY}-01-01`,
endDate: `${maxY}-12-31`
};
}
/** 将接口 reportDate(如 2025-12-28T08:00:00)格式化为 2025年12月28日(按字符串日期部分,避免时区偏移) */
function formatPolicyReportDateToCn(isoStr) {
if (!isoStr) {
return "";
}
const m = String(isoStr).match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
if (!m) {
return "";
}
return `${m[1]}${Number(m[2])}${Number(m[3])}日`;
}
/** 接口 domains 转为标签文案列表 */
function normalizePolicyDomains(domains) {
if (!Array.isArray(domains)) {
return [];
}
return domains
.map((d) => {
if (typeof d === "string") {
return d;
}
return d?.name ?? d?.industryName ?? d?.domainName ?? "";
})
.filter(Boolean);
}
/** 列表行映射:左侧图为 organizations[0].logoUrl,文案与法案/政令用接口字段名 */
function mapPolicyRowToView(row) {
const orgs = Array.isArray(row.organizations) ? row.organizations : [];
const logoUrl = orgs[0]?.logoUrl || defaultNewsIcon;
const reportDateDisplay = formatPolicyReportDateToCn(row.reportDate);
return {
...row,
titleZh: row.titleZh ?? row.title ?? "",
contentZh: row.contentZh ?? row.content ?? "",
reportDateDisplay,
imageUrl: logoUrl,
tagList: normalizePolicyDomains(row.domains),
relatedBills: Array.isArray(row.relatedBills) ? row.relatedBills : [],
relatedAdministrativeOrders: Array.isArray(row.relatedAdministrativeOrders)
? row.relatedAdministrativeOrders
: [],
reportId: row.reportId ?? row.id
};
}
const toDetail = item => {
window.sessionStorage.setItem("curTabName", item.content);
window.sessionStorage.setItem("curTabName", item.contentZh ?? item.content ?? "");
const route = router.resolve({
name: "ReportDetail",
params: {
......@@ -595,10 +1193,11 @@ const toDetail = item => {
};
const total = ref(0);
const sort = ref(false);
/** null:界面显示「发布时间」占位且默认按发布时间倒序;true 正序;false 倒序(显式选中) */
const sort = ref(null);
const handleSwithSort = () => {
sort.value = !sort.value;
handleGetThinkPolicy()();
sort.value = sort.value === true ? false : true;
handleGetThinkPolicy();
};
const currentPage = ref(1);
......@@ -610,32 +1209,58 @@ const handleCurrentChange = page => {
const handleGetThinkPolicy = async () => {
try {
const thinkTankId = router.currentRoute._value.params.id;
const domainIds = selectedAreaList.value
.filter((id) => id != null && id !== "" && id !== POLICY_FILTER_ALL_AREA)
.map((id) => String(id))
.join(",");
const orgIds = selectedDepartmentList.value
.filter((id) => id != null && id !== "" && id !== POLICY_FILTER_ALL_DEPT)
.map((id) => String(id))
.join(",");
const { startDate, endDate } = getPolicyListDateRangeFromYearList(
selectedYearList.value,
selectedYear.value
);
const parmas = {
id: router.currentRoute._value.params.id,
startDate: getDateYearsAgo(selectedYear.value),
sortFun: sort.value,
currentPage: currentPage.value,
thinkTankId,
startDate,
endDate,
pageNum: currentPage.value,
pageSize: 10,
researchTypeIds: selectedAreaList.value.join(",")
sortOrder: sort.value === true ? "asc" : "desc",
sortFun: sort.value === true,
keyword: searchPolicy.value
};
if (domainIds) {
parmas.domainIds = domainIds;
}
if (orgIds) {
parmas.orgIds = orgIds;
}
const res = await getThinkPolicy(parmas);
console.log("智库政策", res);
if (res.code === 200 && res.data) {
policyList.value = res.data.content.map(item => ({
...item,
relationBillsList: relationBillsList.value,
relationAdList: relationAdList.value
}));
total.value = res.data.totalElements;
const list = Array.isArray(res.data.content) ? res.data.content : [];
policyList.value = list.map((row) => mapPolicyRowToView(row));
total.value = res.data.totalElements ?? res.data.total ?? 0;
} else {
// 业务失败(如 code 500、data 为 null)时清空列表,避免仍展示上一次请求的数据
policyList.value = [];
total.value = 0;
}
} catch (error) {
console.error("获取智库政策error", error);
policyList.value = [];
total.value = 0;
}
};
watch(
() => selectedAreaList.value,
val => {
() => selectedYear.value,
() => {
currentPage.value = 1;
handleGetThinkPolicy();
}
);
......@@ -645,6 +1270,7 @@ onMounted(() => {
handleGetThinkPolicyIndustryTotal();
handleGetThinkPolicyIndustryChange();
handleGetHylyList();
handleGetGovAgencyList();
handleGetThinkPolicy();
handleGetPolicyAdviceDeptDistribution();
});
......@@ -655,6 +1281,11 @@ onMounted(() => {
width: 100%;
height: 100%;
.policy-tracking-el-empty {
padding: 0;
margin: 0;
}
.top {
height: 420px;
width: 1600px;
......@@ -674,7 +1305,8 @@ onMounted(() => {
.box-header {
width: 520px;
height: 48px;
border-bottom: 1px solid rgba(234, 236, 238, 1);
box-sizing: border-box;
border-bottom: 1px solid rgba(240, 242, 244, 1);
display: flex;
position: relative;
......@@ -694,8 +1326,8 @@ onMounted(() => {
margin-top: 11px;
margin-left: 18px;
height: 26px;
color: rgba(5, 95, 194, 1);
font-family: Microsoft YaHei;
color: rgba(20, 89, 187, 1);
font-family: "Source Han Sans CN";
font-size: 20px;
font-weight: 700;
line-height: 26px;
......@@ -797,113 +1429,151 @@ onMounted(() => {
}
}
/* 与智库概览 box6-main 一致:主区高度 372、底边距留给 TipTab + AI */
.box-main {
height: 372px;
width: 520px;
height: 372px;
box-sizing: border-box;
padding: 24px 24px 64px 24px;
position: relative;
padding: 24px 24px 0;
&.chart-main--empty {
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
#box1Chart {
height: 309px;
width: 472px;
:deep(.el-empty__image) {
margin-bottom: 0;
}
}
#box1Chart,
#box2Chart {
height: 309px;
width: 472px;
}
#box3Chart {
height: 309px;
width: 472px;
height: 284px;
}
.source {
margin: 0 auto;
margin-top: 10px;
/* 上下0,左右自动居中 */
width: 370px;
position: absolute;
bottom: 21px;
left: 50%;
transform: translateX(-50%);
width: 350px;
height: 22px;
display: flex;
}
.info {
width: 16px;
height: 16px;
margin-top: 3px;
img {
width: 100%;
height: 100%;
}
.chart-box {
position: absolute;
right: 0;
bottom: 18px;
.btn-box {
width: 74px;
height: 28px;
}
.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;
.content-box {
width: 520px;
position: absolute;
right: 0;
bottom: -18px;
}
}
}
.box3-main {
height: 372px;
width: 520px;
height: 372px;
box-sizing: border-box;
padding: 24px 24px 64px 24px;
position: relative;
overflow: hidden;
box-sizing: border-box;
padding-top: 24px;
&.chart-main--empty {
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
overflow: visible;
:deep(.el-empty__image) {
margin-bottom: 0;
}
}
.box3-chart-row {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 8px;
width: 100%;
box-sizing: border-box;
}
#box3Chart {
height: 300px;
width: 520px;
.box3-legend-nav-btn {
flex-shrink: 0;
width: 32px;
height: 32px;
margin-top: 6px;
padding: 0;
box-sizing: border-box;
border: 1px solid rgba(220, 223, 230, 1);
border-radius: 6px;
background: rgba(255, 255, 255, 1);
color: rgba(59, 65, 75, 1);
font-size: 20px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.source {
margin: 0 auto;
margin-top: 10px;
.box3-legend-nav-btn:hover:not(:disabled) {
border-color: var(--color-main-primary, #055fc2);
color: var(--color-main-primary, #055fc2);
}
/* 上下0,左右自动居中 */
width: 370px;
height: 22px;
display: flex;
.box3-legend-nav-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.info {
width: 16px;
height: 16px;
margin-top: 3px;
.box3-chart-canvas {
flex: 1;
min-width: 0;
width: 472px;
height: 284px;
}
img {
width: 100%;
height: 100%;
.source {
position: absolute;
bottom: 21px;
left: 50%;
transform: translateX(-50%);
width: 350px;
height: 22px;
}
display: flex;
}
.chart-box {
position: absolute;
right: 0;
bottom: 18px;
.btn-box {
width: 74px;
height: 28px;
}
.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;
.content-box {
width: 520px;
position: absolute;
right: 0;
bottom: -18px;
}
}
}
......@@ -1133,6 +1803,120 @@ onMounted(() => {
}
}
.select-box-department {
margin-top: 16px;
.select-box-header {
display: flex;
.title {
font-family: "Source Han Sans CN";
font-weight: 700;
font-size: 16px;
line-height: 24px;
letter-spacing: 1px;
text-align: left;
}
}
.select-main {
margin-top: 12px;
}
.checkbox-group {
display: grid;
gap: 8px;
margin-left: 24px;
.filter-checkbox {
height: 24px;
margin: 0;
font-family: "Source Han Sans CN", sans-serif;
font-weight: 400;
font-size: 16px;
line-height: 24px;
letter-spacing: 0px;
text-align: justify;
}
}
.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%;
}
}
}
.icon {
margin-top: 4px;
width: 8px;
height: 16px;
background: var(--color-main-active);
border-radius: 0 4px 4px 0;
}
.title {
color: rgba(5, 95, 194, 1);
margin-left: 17px;
font-family: Microsoft YaHei;
font-style: Bold;
font-size: 20px;
font-weight: 700;
line-height: 26px;
letter-spacing: 0px;
text-align: left;
}
}
.select-box-publish {
margin-top: 16px;
......@@ -1250,7 +2034,7 @@ onMounted(() => {
.right {
width: 1224px;
max-height: 1670px;
min-height: 1670px;
margin-bottom: 20px;
box-sizing: border-box;
border: 1px solid rgba(234, 236, 238, 1);
......@@ -1276,6 +2060,7 @@ onMounted(() => {
padding-left: 37px;
padding-right: 36px;
width: calc(100% + 37px - 36px);
height: 153px;
border-bottom: 1px solid rgba(234, 236, 238, 1);
display: flex;
......@@ -1286,6 +2071,7 @@ onMounted(() => {
img {
width: 100%;
height: 100%;
display: block;
}
}
......@@ -1297,7 +2083,7 @@ onMounted(() => {
.title {
// height: 24px;
color: rgba(59, 65, 75, 1);
color: rgb(59, 65, 75);
font-family: "Source Han Sans CN";
font-size: 18px;
font-weight: 700;
......@@ -1309,15 +2095,26 @@ onMounted(() => {
.info {
margin-top: 7px;
height: 22px;
color: rgba(95, 101, 108, 1);
font-family: "Source Han Sans CN";
font-size: 14px;
font-weight: 400;
line-height: 22px;
letter-spacing: 0px;
text-align: left;
display: flex;
.text {
color: rgba(95, 101, 108, 1);
font-family: "Source Han Sans CN";
font-size: 14px;
font-weight: 400;
line-height: 22px;
letter-spacing: 0px;
text-align: left;
width: 1020px;
white-space: nowrap;
/* 强制不换行,保持一行 */
overflow: hidden;
/* 超出隐藏 */
text-overflow: ellipsis;
/* 超出显示 ... */
}
.more {
width: 16px;
height: 16px;
......
import * as echarts from 'echarts'
import { size, split } from 'lodash'
// data: [{ name: string, value: number[] }, ...]
const getMultiLineChart = (dataX, seriesData) => {
/** 政策追踪「研究领域变化趋势」图例分页:每页条数(与概览数量变化趋势逻辑一致,条数按产品要求为 4) */
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 {
tooltip: {
trigger: 'axis',
......@@ -13,72 +89,78 @@ const getMultiLineChart = (dataX, seriesData) => {
}
}
},
/* 贴满 #box3Chart:四边 0,由 containLabel 在网格内为轴文字留位,避免左侧/底部大块留白 */
grid: {
top: '15%',
right: '5%',
bottom: '5%',
left: '5%',
top: 50,
right: 10,
bottom: 0,
left: 20,
containLabel: true
},
legend: {
icon:'circle',
show: true,
top: 10,
left:'10%',
type: 'plain',
data: legendData,
top: 4,
left: 'center',
icon: 'circle',
textStyle: {
fontSize: 16,
fontFamily: 'Source Han Sans CN',
fontWeight: 400,
lineHeight: 24,
letterSpacing: 0,
align: 'left',
color:'rgb(95, 101, 108)'
fontFamily: 'Source Han Sans CN',
fontWeight: 400,
fontSize: 14,
lineHeight: 24,
letterSpacing: 0,
align: 'left',
color: 'rgb(95, 101, 108)'
},
itemWidth:12,
itemHeight:12,
itemWidth: 12,
itemHeight: 12
},
color: colorList,
xAxis: [
{
type: 'category',
boundaryGap: false,
data: dataX,
axisLine: {
lineStyle: {
color: 'rgb(231, 243, 255)',
},
},
axisLabel: {
color: 'rgb(132, 136, 142)',
fontFamily: 'Microsoft YaHei',
fontWeight: 400,
fontSize:12,
},
data: title,
axisLine: {
lineStyle: {
color: 'rgb(231, 243, 255)'
}
},
axisLabel: {
color: 'rgb(132, 136, 142)',
fontFamily: 'Source Han Sans CN',
fontWeight: 400,
fontSize: labelFontSize,
interval: 0,
hideOverlap: false,
rotate: labelRotate,
margin: 6
}
}
],
yAxis: [
{
type: 'value',
splitLine:{
show:true,
lineStyle:{
color:"rgb(231, 243, 255)",
type:'dashed'
splitNumber: 4,
axisLabel: {
color: 'rgb(132, 136, 142)',
fontFamily: 'Source Han Sans CN',
fontWeight: 400,
fontSize: 11,
margin: 6
},
splitLine: {
show: true,
lineStyle: {
color: 'rgb(231, 243, 255)',
type: 'dashed'
}
}
}
],
series: (seriesData || []).map(item => ({
name: item.name,
type: 'line',
emphasis: { focus: 'series' },
data: item.value
}))
series: echartsSeries
}
}
export default getMultiLineChart
\ No newline at end of file
export default getMultiLineChart
......@@ -180,7 +180,7 @@
</div>
</AnalysisBox>
</div>
<div class="box">
<div class="box box-core-researchers">
<!-- <div class="box-header">
<div class="header-left"></div>
<div class="title">核心研究人员</div>
......@@ -213,16 +213,17 @@
</div>
</div>
</div> -->
<AnalysisBox title="核心研究人员">
<AnalysisBox title="核心研究人员" width="1104px" height="900px">
<div class="box3-main">
<div class="box3-main-left">
<div class="box3-main-top">
<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 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="icon" @click="handleClickPerson(item)">
<img :src="item.imageUrl ? item.imageUrl : DefaultIcon1" alt="" />
......@@ -244,7 +245,7 @@
</template>
<script setup>
import { ref, onMounted } from "vue";
import { ref, onMounted, nextTick } from "vue";
import setChart from "@/utils/setChart";
import getPieChart from "./utils/piechart";
import getTreeMapChart from "./utils/treeMapChart";
......@@ -602,7 +603,7 @@ const handleBox3Chart = () => {
const box3Chart = getTreeMapChart(treemapData);
delete box3Chart.series[0].itemStyle;
box3Chart.series[0].sort = false;
// 图表充满 box3-main-left,左右各留 12px
// 图表充满 box3-main-top,左右各留 12px
box3Chart.series[0].left = 12;
box3Chart.series[0].right = 12;
box3Chart.series[0].top = 0;
......@@ -610,11 +611,10 @@ const handleBox3Chart = () => {
// 方块内文字与区域标题统一为黑色,方块内文字加 1px 白色描边(等效 CSS text-stroke: 1px #fff)
box3Chart.series[0].label = {
...box3Chart.series[0].label,
color: 'rgb(51,51,51)',
textBorderColor: '#fff',
textBorderWidth: 0.7,
// 可选:白色阴影兜底
textShadowColor: '#fff',
color: 'white',
fontSize: 16,
textShadowBlur: 0,
textShadowOffsetX: 0,
textShadowOffsetY: 0
......@@ -739,7 +739,9 @@ onMounted(() => {
handleGetThinkTankResearchAreae()
handleGetThinkPerson()
handleBox3Chart();
nextTick(() => {
handleBox3Chart();
});
});
</script>
......@@ -876,7 +878,8 @@ onMounted(() => {
.right {
width: 1104px;
height: 1245px;
/* 三栏:390 + 390 + 900,间距 16×2 */
height: 1712px;
margin-top: 16px;
display: flex;
flex-direction: column;
......@@ -885,6 +888,7 @@ onMounted(() => {
.box {
width: 1104px;
height: 390px;
flex-shrink: 0;
// box-sizing: border-box;
// border: 1px solid rgba(234, 236, 238, 1);
// border-radius: 10px;
......@@ -1230,59 +1234,62 @@ onMounted(() => {
.box3-main {
display: flex;
flex-direction: column;
.box3-main-left {
width: 536px;
height: 326px;
margin-left: 9px;
box-sizing: border-box;
.box3-main-top {
width: 1100px;
height: 372px;
box-sizing: border-box;
padding-left: 35px;
#box3Chart {
width: 536px;
height: 290px;
width: 1035px;
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 {
width: 100%;
height: 100%;
.source {
margin: 0 auto;
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-top: 12px;
margin-top: 24px;
width: 536px;
height: 326px;
......@@ -1356,9 +1363,15 @@ onMounted(() => {
}
}
}
}
.box.box-core-researchers {
width: 1104px;
height: 900px;
flex-shrink: 0;
}
}
}
</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 @@
</div>
<div class="select-box">
<div class="search-box">
<el-input placeholder="搜索智库" v-model="searchPolicy">
<el-input placeholder="搜索智库" v-model="searchPolicy" @keyup.enter="handleGetThinkTankList()">
<template #suffix>
<img src="../assets/images/Line_Search.png" class="search-icon" alt="搜索"
@click="handleGetThinkTankList()">
......@@ -70,6 +70,9 @@
{{ "近期美国智库机构发布涉华报告数量汇总" }}
</div>
</div>
<div class="time-tab-pane">
<TimeTabPane @time-click="handleTimeClick" />
</div>
</div>
<div class="all-item">
<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";
import { getAllThinkTankList } from "@/api/thinkTank/overview"
import { useRouter } from 'vue-router';
import router from "@/router";
import TimeTabPane from '@/components/base/TimeTabPane/index.vue'
const cardList = ref([]);
// 按 reportNumber 从大到小排序,保证从左到右从上到下排列
const sortedCardList = computed(() => {
......@@ -125,6 +129,22 @@ const sortedCardList = computed(() => {
const currentPage = ref(1)
const pageSize = ref(15)
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 => {
currentPage.value = page;
handleGetThinkTankList()
......@@ -142,7 +162,8 @@ const handleGetThinkTankList = async () => {
// 后端通常是 0-based,这里做一次转换
currentPage: currentPage.value - 1,
pageSize: pageSize.value,
keyword: searchPolicy.value
keyword: searchPolicy.value,
timePeriod: timePeriod.value
});
console.log("智库列表", res);
cardList.value = [];
......@@ -391,6 +412,7 @@ onMounted(async () => {
height: 36px;
margin-top: 14px;
display: flex;
justify-content: space-between;
.title-info {
height: 24px;
......@@ -419,6 +441,11 @@ onMounted(async () => {
color: rgb(59, 65, 75);
}
}
.time-tab-pane {
width: 248px;
height: 36px;
}
}
.all-item {
......@@ -463,7 +490,8 @@ onMounted(async () => {
padding: 2px 8px;
/* 左右留空隙,更美观 */
white-space: nowrap
white-space: nowrap;
z-index: 9999;
}
.item-header {
......
......@@ -51,6 +51,9 @@
{{ "近期美国智库机构发布涉华报告数量汇总" }}
</div>
</div>
<div class="time-tab-pane">
<TimeTabPane @time-click="handleTimeClick" />
</div>
</div>
<div class="home-main-header-card-box">
<div class="card" v-for="(item, index) in sortedCardList" :key="index" @click="handleClick(item)">
......@@ -173,22 +176,50 @@
<div class="box5-header-icon">
<img src="./assets/images/box3-header-icon.png" alt="" />
</div>
<div class="box5-header-title">{{ "政策建议趋势分布" }}</div>
<div class="box5-header-title">{{ "数量变化趋势" }}</div>
</div>
<div class="box5-select-box">
<el-select v-model="box5selectetedMonths" placeholder="选择时间" style="width: 120px">
<el-option v-for="item in box5MonthsList" :key="item.value" :label="item.label" :value="item.value"
@click="changeBox5Data(item.value)" />
<el-select v-model="box5selectetedArea" placeholder="选择领域" style="width: 120px"
@change="handleBox5AreaChange">
<el-option label="全部领域" value="全部领域" />
<el-option v-for="item in box5RawData.data" :key="item.name" :label="item.name" :value="item.name" />
</el-select>
<el-select v-model="box5selectetedYear" placeholder="选择年份" style="width: 120px"
@change="changeBox5Data">
<el-option v-for="item in box5YearList" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</div>
</div>
<div class="box5-main">
<el-empty v-if="!hasBox5ChartData" description="暂无数据" :image-size="100" />
<div id="box5Chart" v-else></div>
<div class="source">
<div class="info"><img src="./assets/images/image-exclamation.png"></div>
<div class="text"> 数据来源:美国国会官网,数据时间:2015.1至2025.12</div>
</div>
<div class="box5-main" :class="{ 'box5-main--empty': !hasBox5ChartData }">
<template v-if="!hasBox5ChartData">
<el-empty class="box5-el-empty" description="暂无数据" :image-size="100" />
</template>
<template v-else>
<div class="box5-chart-row">
<button v-if="box5LegendShowNav" type="button" class="box5-legend-nav-btn"
:disabled="!box5LegendCanPrev" aria-label="上一页图例" @click="handleBox5LegendPrev">
</button>
<div id="box5Chart" class="box5-chart-canvas"></div>
<button v-if="box5LegendShowNav" type="button" class="box5-legend-nav-btn"
:disabled="!box5LegendCanNext" aria-label="下一页图例" @click="handleBox5LegendNext">
</button>
</div>
<div class="source">
<TipTab :text="'智库报告数量变化趋势,数据来源:美国各智库官网'" />
</div>
<div class="chart-box">
<div class="btn-box" v-if="!isShowAiContentBox5" @mouseenter="handleSwitchAiContentShowBox5(true)">
<AiButton />
</div>
<div class="content-box" v-if="isShowAiContentBox5"
@mouseleave="handleSwitchAiContentShowBox5(false)">
<AiPane :aiContent="aiContentBox5" />
</div>
</div>
</template>
</div>
</div>
<div class="box6">
......@@ -196,20 +227,39 @@
<div class="header-icon">
<img src="./assets/images/box6-header-icon.png" alt="" />
</div>
<div class="header-title">{{ "政策建议领域分布" }}</div>
<div class="header-title">{{ "领域分布情况" }}</div>
<div class="box6-select-box">
<el-select v-model="box6selectetedTank" placeholder="选择智库" style="width: 120px">
<el-option label="全部智库" value="全部智库" />
<el-option v-for="item in box6TankList" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-select v-model="box6selectetedYear" placeholder="选择时间" style="width: 120px">
<el-option v-for="item in box6YearList" :key="item.value" :label="item.label" :value="item.value"
@click="handleBox6()" />
</el-select>
</div>
</div>
<div class="box6-main">
<div id="box6Chart"></div>
<div class="source">
<div class="info"><img src="./assets/images/image-exclamation.png"></div>
<div class="text"> 数据来源:美国国会官网,数据时间:2015.1至2025.12</div>
</div>
<div class="box6-main" :class="{ 'box6-main--empty': !hasBox6ChartData }">
<template v-if="!hasBox6ChartData">
<el-empty class="box6-el-empty" description="暂无数据" :image-size="100" />
</template>
<template v-else>
<div id="box6Chart"></div>
<div class="source">
<TipTab :text="'智库报告领域分布情况,数据来源:美国各智库官网'" />
</div>
<div class="chart-box">
<div class="btn-box" v-if="!isShowAiContentBox6" @mouseenter="handleSwitchAiContentShowBox6(true)">
<AiButton />
</div>
<div class="content-box" v-if="isShowAiContentBox6"
@mouseleave="handleSwitchAiContentShowBox6(false)">
<AiPane :aiContent="aiContentBox6" />
</div>
</div>
</template>
</div>
</div>
</div>
......@@ -223,7 +273,26 @@
<div class="box7-header-title">{{ "智库资金流向" }}</div>
</div>
</div>
<div class="box7-main" id="box7Chart"></div>
<div class="box7-main" :class="{ 'box7-main--empty': !hasBox7ChartData }">
<template v-if="!hasBox7ChartData">
<el-empty class="box7-el-empty" description="暂无数据" :image-size="100" />
</template>
<template v-else>
<div id="box7Chart"></div>
<div class="source">
<TipTab :text="'美国科技智库与主要政府机构之间的资金往来,数据来源:美国各智库官网'" />
</div>
<div class="chart-box">
<div class="btn-box" v-if="!isShowAiContentBox7" @mouseenter="handleSwitchAiContentShowBox7(true)">
<AiButton />
</div>
<div class="content-box" v-if="isShowAiContentBox7"
@mouseleave="handleSwitchAiContentShowBox7(false)">
<AiPane :aiContent="aiContentBox7" />
</div>
</div>
</template>
</div>
</div>
<div class="box8">
<div class="box8-header">
......@@ -233,31 +302,30 @@
</div>
<div class="box8-header-title">{{ "智库研究热点" }}</div>
</div>
<div class="box8-select-box">
<el-select v-model="box8selectetedYear" placeholder="选择时间" style="width: 120px">
<el-option v-for="item in box8YearList" :key="item.value" :label="item.label" :value="item.value"
@click="changeBox8Data(item.value)" />
</el-select>
</div>
</div>
<div class="box8-main">
<div class="box8-item" v-for="(item, index) in box8Data" :key="index">
<div class="item-left"
:class="{ itemBold1: index === 0, itemBold2: index === 1, itemBold3: index === 2 }">
{{ index + 1 }}
</div>
<!-- <el-popover effect="dark" :content="item.clause" placement="top-start">
<div class="box8-main-item">
<div class="box8-item" v-for="(item, index) in box8Data" :key="index">
<div class="item-left"
:class="{ itemBold1: index === 0, itemBold2: index === 1, itemBold3: index === 2 }">
{{ index + 1 }}
</div>
<!-- <el-popover effect="dark" :content="item.clause" placement="top-start">
<template #reference> -->
<div class="item-center"
:class="{ itemBold1: index === 0, itemBold2: index === 1, itemBold3: index === 2 }">
{{ item.clause }}
</div>
<!-- </template>
<div class="item-center"
:class="{ itemBold1: index === 0, itemBold2: index === 1, itemBold3: index === 2 }">
{{ item.clause }}
</div>
<!-- </template>
</el-popover> -->
<!-- <div class="item-right">{{ `${item.count}份报告 >` }}</div> -->
<div class="item-count">{{ item.count + "份报告 >" }}</div>
<!-- <div class="item-right">{{ `${item.count}份报告 >` }}</div> -->
<div class="item-count">{{ item.count + "份报告 >" }}</div>
</div>
</div>
</div>
</div>
</div>
......@@ -355,9 +423,10 @@ import {
getThinkTankReportRemarks
} from "@/api/thinkTank/overview";
import { getPersonSummaryInfo } from "@/api/common/index";
import getMultiLineChart from "./utils/multiLineChart";
import getMultiLineChart, { MULTILINE_LEGEND_SHOW_COUNT } from "./utils/multiLineChart";
import getPieChart from "./utils/piechart";
import getSankeyChart from "./utils/sankey";
import { getChartAnalysis } from "@/api/aiAnalysis/index";
import defaultNewsIcon from "@/assets/icons/default-icon-news.png";
import defaultHeaderIcin from "@/assets/icons/default-icon1.png";
import News1 from "./assets/images/news1.png";
......@@ -365,6 +434,9 @@ import News2 from "./assets/images/news2.png";
import News3 from "./assets/images/news3.png";
import News4 from "./assets/images/news4.png";
import News5 from "./assets/images/news5.png";
import AiButton from '@/components/base/Ai/AiButton/index.vue'
import AiPane from '@/components/base/Ai/AiPane/index.vue'
import TipTab from "./TipTab/index.vue";
import Message1 from "./assets/images/message-icon1.png";
import Message2 from "./assets/images/message-icon2.png";
......@@ -398,12 +470,40 @@ const containerRef = ref(null);
const statCountInfo = ref([]);
const pageSize = ref(15)
const totalAllItem = ref(0)
const isShowAiContentBox5 = ref(false);
const aiContentBox5 = ref("");
const isBox5InterpretLoading = ref(false);
const handleSwitchAiContentShowBox5 = (val) => {
isShowAiContentBox5.value = val;
if (val) {
fetchBox5ChartInterpretation();
}
};
const isShowAiContentBox6 = ref(false);
const aiContentBox6 = ref("");
const isBox6InterpretLoading = ref(false);
const handleSwitchAiContentShowBox6 = (val) => {
isShowAiContentBox6.value = val;
if (val) {
fetchBox6ChartInterpretation();
}
};
const isShowAiContentBox7 = ref(false);
const aiContentBox7 = ref("");
const isBox7InterpretLoading = ref(false);
const handleSwitchAiContentShowBox7 = (val) => {
isShowAiContentBox7.value = val;
if (val) {
fetchBox7ChartInterpretation();
}
};
const handleGetAllThinkTankList = async () => {
try {
const res = await getAllThinkTankList({
// 后端通常是 0-based,这里做一次转换
currentPage: currentPage.value - 1,
pageSize: pageSize.value
pageSize: pageSize.value,
timePeriod: timePeriod.value
});
console.log("智库列表", res);
if (res.code === 200 && res.data) {
......@@ -421,6 +521,23 @@ const goToAllThinkTank = () => {
// 替换为你的实际路由路径
routerTo.push('/thinkTank/allThinkTank');
};
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
handleGetAllThinkTankList()
// 同步刷新首页卡片数据(包含 increaseReportNumber)
handleGetThinkTankList()
}
const getStatCountInfo = async () => {
......@@ -497,21 +614,31 @@ const sortedCardList = computed(() => {
// 获取智库列表
const handleGetThinkTankList = async () => {
try {
const res = await getThinkTankList();
const res = await getAllThinkTankList({
// 后端通常是 0-based,这里做一次转换
currentPage: currentPage.value - 1,
pageSize: pageSize.value,
timePeriod: timePeriod.value
});
console.log("智库列表", res);
cardList.value = [];
if (res.code === 200 && res.data) {
cardList.value = res.data.map(item => {
return {
id: item.id,
logo: item.imageUrl,
name: item.name,
tagList: item.tags,
country: item.country,
desc: item.describe,
rank: item.id,
reportNumber: item.reportNumber
};
});
// 兼容两种返回结构:
// 1) data.content(分页)
// 2) data 直接是数组(非分页)
const list = Array.isArray(res.data) ? res.data : (res.data?.content || []);
cardList.value = list.map(item => ({
id: item.id,
logo: item.imageUrl,
name: item.name,
tagList: item.tags,
country: item.country,
desc: item.describe,
rank: item.id,
reportNumber: item.reportNumber,
increaseReportNumber: item.increaseReportNumber
}));
}
} catch (error) {
console.error("获取智库列表error", error);
......@@ -759,26 +886,13 @@ const handleGetThinkTankReportRemarks = async () => {
}
};
// 政策建议趋势分布
const box5Data = ref({
// title: ["2014", "2015", "2016", "2017", "2018", "2019", "2020", "2021", "2022", "2023", "2024", "2025"],
// data: [
// {
// name: "人工智能",
// value: [73, 32, 42, 48, 38, 49, 63, 75, 70, 86, 95, 87]
// },
// {
// name: "集成电路",
// value: [8, 3, 2, 8, 9, 10, 12, 18, 16, 18, 20, 22]
// },
// {
// name: "量子科技",
// value: [1, 2, 1, 1, 1, 1, 1, 2, 1, 1, 2, 3]
// }
// ]
// 政策建议趋势分布(接口全量,按领域筛选后用于绘图)
const box5RawData = ref({
title: [],
data: []
});
const hasBox5ChartData = computed(() => {
const v = box5Data.value;
const v = box5RawData.value;
if (!v || typeof v !== "object") return false;
return Array.isArray(v.title) && v.title.length > 0 && Array.isArray(v.data) && v.data.length > 0;
});
......@@ -807,33 +921,34 @@ function getDateMonthsAgo(months) {
const day = String(pastDate.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
const box5selectetedMonths = ref(24);
const box5MonthsList = ref([
{
label: "近3个月",
value: 3
},
{
label: "近6个月",
value: 6
},
{
label: "近12个月",
value: 12
},
{
label: "近24个月",
value: 24
}
/** 自然年日期范围(传给 policyIndustryChange) */
const getBox5YearDateRange = year => {
const y = Number(year);
return {
startDate: `${y}-01-01`,
endDate: `${y}-12-31`
};
};
const box5selectetedYear = ref(2025);
const box5YearList = ref([
{ label: "2026", value: 2026 },
{ label: "2025", value: 2025 },
{ label: "2024", value: 2024 },
{ label: "2023", value: 2023 },
{ label: "2022", value: 2022 }
]);
function changeBox5Data(value) {
handleBox5(getDateMonthsAgo(value));
}
const box5selectetedArea = ref("全部领域");
const changeBox5Data = year => {
handleBox5(year);
};
// 政策建议趋势分布
const handleGetThinkTankPolicyIndustryChange = async date => {
const handleGetThinkTankPolicyIndustryChange = async range => {
try {
const res = await getThinkTankPolicyIndustryChange(date);
const res = await getThinkTankPolicyIndustryChange(range);
console.log("政策建议趋势分布", res);
if (res.code === 200 && res.data) {
const originalData = res.data;
......@@ -865,21 +980,153 @@ const handleGetThinkTankPolicyIndustryChange = async date => {
});
result.data.push(industryData);
});
box5Data.value = result;
box5RawData.value = result;
console.log(box5RawData.value.data, "box5RawData");
box5RawData.value.data.forEach(item => {
console.log(item.name);
});
} else {
box5Data.value = { title: [], data: [] };
box5RawData.value = { title: [], data: [] };
}
} catch (error) {
console.error("获取政策建议趋势分布error", error);
box5RawData.value = { title: [], data: [] };
}
};
/** 按所选领域得到折线图数据(全部领域 = 全量序列) */
const box5ChartDisplayData = computed(() => {
const raw = box5RawData.value;
if (!raw || !Array.isArray(raw.title) || !Array.isArray(raw.data)) {
return { title: [], data: [] };
}
if (box5selectetedArea.value === "全部领域") {
return { title: raw.title, data: [...raw.data] };
}
const series = raw.data.filter(d => d.name === box5selectetedArea.value);
return {
title: raw.title,
data: series.length ? series : [...raw.data]
};
});
const box5LegendPageIndex = ref(0);
const box5LegendPageCount = computed(() => {
const n = box5ChartDisplayData.value?.data?.length ?? 0;
return Math.max(1, Math.ceil(n / MULTILINE_LEGEND_SHOW_COUNT));
});
const box5LegendShowNav = computed(() => {
const n = box5ChartDisplayData.value?.data?.length ?? 0;
return n > MULTILINE_LEGEND_SHOW_COUNT;
});
const box5LegendCanPrev = computed(() => box5LegendPageIndex.value > 0);
const box5LegendCanNext = computed(
() => box5LegendPageIndex.value < box5LegendPageCount.value - 1
);
const handleBox5LegendPrev = () => {
if (box5LegendPageIndex.value <= 0) {
return;
}
box5LegendPageIndex.value -= 1;
renderBox5Chart();
};
const handleBox5LegendNext = () => {
if (box5LegendPageIndex.value >= box5LegendPageCount.value - 1) {
return;
}
box5LegendPageIndex.value += 1;
renderBox5Chart();
};
const handleBox5 = async date => {
await handleGetThinkTankPolicyIndustryChange(date);
let box5Chart = box5Data.value ? getMultiLineChart(box5Data.value) : "";
const renderBox5Chart = () => {
const chartInput = box5ChartDisplayData.value;
if (!chartInput.title.length || !chartInput.data.length) {
return;
}
const totalNames = chartInput.data.length;
const pages = Math.max(1, Math.ceil(totalNames / MULTILINE_LEGEND_SHOW_COUNT));
if (box5LegendPageIndex.value >= pages) {
box5LegendPageIndex.value = pages - 1;
}
if (box5LegendPageIndex.value < 0) {
box5LegendPageIndex.value = 0;
}
const box5Chart = getMultiLineChart(chartInput, {
legendShowCount: MULTILINE_LEGEND_SHOW_COUNT,
legendPageIndex: box5LegendPageIndex.value
});
setChart(box5Chart, "box5Chart");
};
const handleBox5AreaChange = () => {
aiContentBox5.value = "";
box5LegendPageIndex.value = 0;
renderBox5Chart();
};
const handleBox5 = async year => {
const y = year != null ? Number(year) : box5selectetedYear.value;
box5selectetedYear.value = y;
box5selectetedArea.value = "全部领域";
box5LegendPageIndex.value = 0;
await handleGetThinkTankPolicyIndustryChange(getBox5YearDateRange(y));
renderBox5Chart();
aiContentBox5.value = "";
};
/** 请求 box5 折线图解读并更新 aiContentBox5(支持流式 SSE) */
const fetchBox5ChartInterpretation = async () => {
const v = box5ChartDisplayData.value;
if (!v || !Array.isArray(v.title) || v.title.length === 0 || !Array.isArray(v.data) || v.data.length === 0) {
aiContentBox5.value = "暂无图表数据";
return;
}
const hasValidContent =
aiContentBox5.value &&
aiContentBox5.value !== "解读生成中…" &&
aiContentBox5.value !== "解读加载失败" &&
aiContentBox5.value !== "暂无图表数据";
if (hasValidContent || isBox5InterpretLoading.value) {
return;
}
isBox5InterpretLoading.value = true;
aiContentBox5.value = "解读生成中…";
const chartPayload = {
type: "折线图",
name: "数量变化趋势",
data: v.title.map((label, i) => {
const point = { period: label };
v.data.forEach((s) => {
point[s.name] = s.value[i] ?? 0;
});
return point;
})
};
try {
const text = await getChartAnalysis(
{ text: JSON.stringify(chartPayload) },
{
onInterpretationDelta: (chunk) => {
if (chunk) {
aiContentBox5.value = chunk;
}
}
}
);
aiContentBox5.value = text || "未返回有效解读内容";
} catch (error) {
console.error("图表解读请求失败", error);
aiContentBox5.value = "解读加载失败";
} finally {
isBox5InterpretLoading.value = false;
}
};
// 政策建议领域分布
const box6Data = ref([
{
......@@ -920,6 +1167,10 @@ const box6Data = ref([
]);
const box6selectetedYear = ref("2025");
const box6YearList = ref([
{
label: "2026",
value: "2026"
},
{
label: "2025",
value: "2025"
......@@ -937,6 +1188,94 @@ const box6YearList = ref([
value: "2022"
}
]);
const box6selectetedArea = ref("全部领域");
/** 领域分布:有接口返回的有效分项时才展示图表(与 box5 空态一致) */
const hasBox6ChartData = computed(() => {
const list = Array.isArray(box6Data.value) ? box6Data.value : [];
return list.length > 0;
});
/** 按所选领域得到饼图数据(全部领域 = 全部分块) */
const box6PieDisplayData = computed(() => {
const list = Array.isArray(box6Data.value) ? box6Data.value : [];
if (!list.length) {
return [];
}
if (box6selectetedArea.value === "全部领域") {
return list;
}
const filtered = list.filter(item => item.name === box6selectetedArea.value);
if (filtered.length) {
return filtered;
}
return [{ name: "暂无该领域数据", value: 1, color: "#E0E0E0" }];
});
/** 饼图解读用数据:排除占位项 */
const getBox6InterpretationSeries = () => {
const pie = box6PieDisplayData.value;
return pie.filter(
item => item.name !== "暂无数据" && item.name !== "暂无该领域数据"
);
};
/** 请求 box6 领域分布饼图解读(入参:{ text: JSON.stringify({ type, name, data }) },与柱状图示例同结构) */
const fetchBox6ChartInterpretation = async () => {
const series = getBox6InterpretationSeries();
if (!series.length) {
aiContentBox6.value = "暂无图表数据";
return;
}
const hasValidContent =
aiContentBox6.value &&
aiContentBox6.value !== "解读生成中…" &&
aiContentBox6.value !== "解读加载失败" &&
aiContentBox6.value !== "暂无图表数据";
if (hasValidContent || isBox6InterpretLoading.value) {
return;
}
isBox6InterpretLoading.value = true;
aiContentBox6.value = "解读生成中…";
const chartPayload = {
type: "饼图",
name: "领域分布情况",
year: box6selectetedYear.value,
data: series.map(item => ({
name: item.name,
value: item.value
}))
};
try {
const text = await getChartAnalysis(
{ text: JSON.stringify(chartPayload) },
{
onInterpretationDelta: (chunk) => {
if (chunk) {
aiContentBox6.value = chunk;
}
}
}
);
aiContentBox6.value = text || "未返回有效解读内容";
} catch (error) {
console.error("领域分布图表解读请求失败", error);
aiContentBox6.value = "解读加载失败";
} finally {
isBox6InterpretLoading.value = false;
}
};
const box6AreaList = ref([
]);
const box6selectetedTank = ref("全部智库");
const box6TankList = ref([
{
label: "兰德公司",
value: "兰德公司"
}
]);
function transformToChartFormat(data) {
// 预设颜色池(可按需修改或扩展)
const colorPalette = ["#4096FF", "#FFA39E", "#ADC6FF", "#FFC069", "#B5F5EC", "#B37FEB", "#D6E4FF", "#FF8C8C", "#87E8DE"];
......@@ -965,52 +1304,49 @@ const handleGetThinkTankPolicyIndustry = async () => {
}
} catch (error) {
console.error("获取政策建议领域分布error", error);
box6Data.value = [];
}
};
const renderBox6Chart = () => {
if (!hasBox6ChartData.value) {
return;
}
const pieData = box6PieDisplayData.value;
if (!pieData.length) {
return;
}
const box6Chart = getPieChart(pieData);
setChart(box6Chart, "box6Chart");
};
const handleBox6AreaChange = () => {
aiContentBox6.value = "";
renderBox6Chart();
};
const handleBox6 = async () => {
box6selectetedArea.value = "全部领域";
aiContentBox6.value = "";
await handleGetThinkTankPolicyIndustry();
const box6Chart = getPieChart(box6Data.value);
setChart(box6Chart, "box6Chart");
renderBox6Chart();
};
// 智库资金流向
const box7Data = ref({
nodes: [
{ name: "美国", itemStyle: { color: "#1f5cb8" } },
{ name: "英国", itemStyle: { color: "#6f42c1" } },
{ name: "加拿大", itemStyle: { color: "#d9534f" } },
// 中间层
{ name: "美国能源部", itemStyle: { color: "#a4c639" } },
{ name: "美国财政部", itemStyle: { color: "#f06292" } },
{ name: "美国国务院", itemStyle: { color: "#b08d55" } },
{ name: "美国国际开发署", itemStyle: { color: "#4285f4" } },
{ name: "美国内政部", itemStyle: { color: "#8cae9e" } },
{ name: "美国国家民主基金会", itemStyle: { color: "#a1887f" } },
// 右侧层 (部分)
{ name: "麻省理工学院科技评论", itemStyle: { color: "#7cb342" } },
{ name: "麦肯锡全球研究院", itemStyle: { color: "#cddc39" } },
{ name: "卡内基国际和平研究院", itemStyle: { color: "#afb42b" } },
{ name: "战略与国际研究中心", itemStyle: { color: "#827717" } },
{ name: "兰德公司", itemStyle: { color: "#689f38" } }
],
links: [
{ source: "美国", target: "美国能源部", value: 50 },
{ source: "美国", target: "美国财政部", value: 40 },
{ source: "美国", target: "美国国务院", value: 60 },
{ source: "美国", target: "美国国际开发署", value: 20 },
{ source: "美国", target: "美国内政部", value: 15 },
{ source: "英国", target: "美国国务院", value: 10 },
{ source: "加拿大", target: "美国能源部", value: 5 },
// Flow to right
{ source: "美国能源部", target: "麻省理工学院科技评论", value: 20 },
{ source: "美国能源部", target: "麦肯锡全球研究院", value: 25 },
{ source: "美国财政部", target: "卡内基国际和平研究院", value: 30 },
{ source: "美国国务院", target: "战略与国际研究中心", value: 40 },
{ source: "美国国务院", target: "兰德公司", value: 15 }
]
nodes: [],
links: []
});
/** 资金流向:有节点与连线时才展示桑基图 */
const hasBox7ChartData = computed(() => {
const raw = box7Data.value;
if (!raw || typeof raw !== "object") {
return false;
}
const nodes = Array.isArray(raw.nodes) ? raw.nodes : [];
const links = Array.isArray(raw.links) ? raw.links : [];
return nodes.length > 0 && links.length > 0;
});
// 遍历每个智库
......@@ -1085,19 +1421,77 @@ const handleGetThinkTankDonation = async () => {
try {
const res = await getThinkTankDonation();
console.log("智库资金流向", res.data, transformDataToSankey(res.data));
if (res.code === 200 && res.data) {
if (res.code === 200 && Array.isArray(res.data)) {
box7Data.value = transformDataToSankey(res.data);
} else {
box7Data.value = { nodes: [], links: [] };
}
} catch (error) {
console.error("获取智库资金流向error", error);
box7Data.value = { nodes: [], links: [] };
}
};
const handleBox7 = async () => {
aiContentBox7.value = "";
await handleGetThinkTankDonation();
const box7Chart = getSankeyChart(box7Data.value.nodes, box7Data.value.links);
if (!hasBox7ChartData.value) {
return;
}
const nodes = box7Data.value?.nodes ?? [];
const links = box7Data.value?.links ?? [];
const box7Chart = getSankeyChart(nodes, links);
setChart(box7Chart, "box7Chart");
};
/** 请求 box7 智库资金流向桑基图解读(入参:{ text: JSON.stringify({ type, name, nodes, links }) }) */
const fetchBox7ChartInterpretation = async () => {
const raw = box7Data.value;
const nodes = Array.isArray(raw?.nodes) ? raw.nodes : [];
const links = Array.isArray(raw?.links) ? raw.links : [];
if (!nodes.length && !links.length) {
aiContentBox7.value = "暂无图表数据";
return;
}
const hasValidContent =
aiContentBox7.value &&
aiContentBox7.value !== "解读生成中…" &&
aiContentBox7.value !== "解读加载失败" &&
aiContentBox7.value !== "暂无图表数据";
if (hasValidContent || isBox7InterpretLoading.value) {
return;
}
isBox7InterpretLoading.value = true;
aiContentBox7.value = "解读生成中…";
const chartPayload = {
type: "桑基图",
name: "智库资金流向",
nodes: nodes.map(n => ({ name: n.name })),
links: links.map(l => ({
source: l.source,
target: l.target,
value: l.value
}))
};
try {
const text = await getChartAnalysis(
{ text: JSON.stringify(chartPayload) },
{
onInterpretationDelta: (chunk) => {
if (chunk) {
aiContentBox7.value = chunk;
}
}
}
);
aiContentBox7.value = text || "未返回有效解读内容";
} catch (error) {
console.error("智库资金流向图表解读请求失败", error);
aiContentBox7.value = "解读加载失败";
} finally {
isBox7InterpretLoading.value = false;
}
};
// 智库研究热点
const box8Data = ref([
// {
......@@ -1806,7 +2200,7 @@ onMounted(async () => {
handleGetNewReport();
handleGetThinkTankRiskSignal();
handleBox5(getDateMonthsAgo(24));
handleBox5(box5selectetedYear.value);
handleBox6();
handleBox7();
handleGetHylyList();
......@@ -2068,6 +2462,12 @@ onMounted(async () => {
height: 36px;
margin-top: 64px;
display: flex;
justify-content: space-between;
.time-tab-pane {
width: 248px;
height: 36px;
}
.title-info {
height: 24px;
......@@ -2104,6 +2504,9 @@ onMounted(async () => {
display: flex;
justify-content: space-between;
gap: 16px;
position: relative;
z-index: 9999999;
overflow: visible;
.card {
width: 253px;
......@@ -2116,12 +2519,12 @@ onMounted(async () => {
transition: all 0.3s;
cursor: pointer;
position: relative; // 让 red-info 按当前 card 自身定位
z-index: 1;
z-index: 9999998;
&:hover {
transform: translateY(-3px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
z-index: 2;
z-index: 9999999;
}
.red-info {
......@@ -2145,7 +2548,8 @@ onMounted(async () => {
padding: 2px 8px;
/* 左右留空隙,更美观 */
white-space: nowrap
white-space: nowrap;
z-index: 10000000;
}
.card-header {
......@@ -3161,11 +3565,13 @@ onMounted(async () => {
}
.box5-select-box {
width: 120px;
width: 240px;
height: 28px;
position: absolute;
top: 11px;
right: 31px;
display: flex;
gap: 2px;
}
}
......@@ -3173,15 +3579,76 @@ onMounted(async () => {
height: 412px;
width: 1063px;
box-sizing: border-box;
position: relative;
padding: 24px 24px 64px 24px;
&.box5-main--empty {
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
:deep(.el-empty__image) {
margin-bottom: 0;
}
}
.box5-el-empty {
padding: 0;
margin: 0;
}
.box5-chart-row {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 8px;
width: 100%;
box-sizing: border-box;
}
#box5Chart {
height: 340px;
width: 1063px;
.box5-legend-nav-btn {
flex-shrink: 0;
width: 32px;
height: 32px;
margin-top: 6px;
padding: 0;
box-sizing: border-box;
border: 1px solid rgba(220, 223, 230, 1);
border-radius: 6px;
background: rgba(255, 255, 255, 1);
color: rgba(59, 65, 75, 1);
font-size: 20px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.box5-legend-nav-btn:hover:not(:disabled) {
border-color: var(--color-main-primary, #055fc2);
color: var(--color-main-primary, #055fc2);
}
.box5-legend-nav-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.box5-chart-canvas {
flex: 1;
min-width: 0;
height: 324px;
}
.source {
margin: 0 auto;
margin-top: 23px;
position: absolute;
bottom: 21px;
left: 50%;
/* 让元素左边对齐父级中线 */
transform: translateX(-50%);
/* 上下0,左右自动居中 */
width: 370px;
......@@ -3213,6 +3680,25 @@ onMounted(async () => {
margin-left: 8px;
}
}
.chart-box {
position: absolute;
right: 0;
bottom: 18px;
.btn-box {
width: 74px;
height: 28px;
}
.content-box {
width: 1063px;
position: absolute;
right: 0;
bottom: -18px;
}
}
}
}
......@@ -3258,11 +3744,13 @@ onMounted(async () => {
}
.box6-select-box {
width: 120px;
width: 240px;
height: 28px;
position: absolute;
top: 12px;
right: 25px;
display: flex;
gap: 2px
}
}
......@@ -3270,16 +3758,38 @@ onMounted(async () => {
width: 520px;
height: 412px;
box-sizing: border-box;
padding: 24px 24px 0;
padding: 24px 24px 64px 24px;
position: relative;
&.box6-main--empty {
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
:deep(.el-empty__image) {
margin-bottom: 0;
}
}
.box6-el-empty {
padding: 0;
margin: 0;
}
#box6Chart {
width: 446px;
height: 309px;
width: 472px;
height: 324px;
}
.source {
margin: 0 auto;
margin-top: 23px;
position: absolute;
bottom: 21px;
left: 50%;
/* 让元素左边对齐父级中线 */
transform: translateX(-50%);
/* 让元素自身向左回退一半宽度 */
/* 上下0,左右自动居中 */
width: 370px;
......@@ -3311,6 +3821,25 @@ onMounted(async () => {
margin-left: 8px;
}
}
.chart-box {
position: absolute;
right: 0;
bottom: 18px;
.btn-box {
width: 74px;
height: 28px;
}
.content-box {
width: 520px;
position: absolute;
right: 0;
bottom: -18px;
}
}
}
......@@ -3403,9 +3932,92 @@ onMounted(async () => {
}
.box7-main {
width: 1064px;
height: 412px;
box-sizing: border-box;
padding: 24px 24px 0;
padding: 24px 24px 64px 24px;
position: relative;
&.box7-main--empty {
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
:deep(.el-empty__image) {
margin-bottom: 0;
}
}
.box7-el-empty {
padding: 0;
margin: 0;
}
#box7Chart {
width: 1016px;
/* 填满宽度 */
height: 324px;
/* 填满高度 */
}
.source {
position: absolute;
bottom: 21px;
left: 50%;
/* 让元素左边对齐父级中线 */
transform: translateX(-50%);
/* 上下0,左右自动居中 */
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;
}
}
.chart-box {
position: absolute;
right: 0;
bottom: 18px;
.btn-box {
width: 74px;
height: 28px;
}
.content-box {
width: 1063px;
position: absolute;
right: 0;
bottom: -18px;
}
}
}
}
......@@ -3464,80 +4076,85 @@ onMounted(async () => {
}
.box8-main {
margin: 0 auto;
margin-top: 5px;
width: 452px;
height: 382px;
overflow: hidden;
.box8-item {
margin-top: 14px;
height: 412px;
.box8-main-item {
margin: 0 auto;
margin-top: 5px;
width: 452px;
height: 24px;
display: flex;
height: 382px;
overflow: hidden;
.item-left {
width: 20px;
.box8-item {
margin-top: 14px;
width: 452px;
height: 24px;
color: rgb(95, 101, 108);
font-family: "Source Han Sans CN";
font-size: 16px;
font-weight: 400;
line-height: 24px;
letter-spacing: 0px;
text-align: center;
}
display: flex;
.item-center {
margin-left: 20px;
width: 425px;
height: 24px;
color: rgb(59, 65, 75);
font-family: "Source Han Sans CN";
font-size: 16px;
font-weight: 400;
line-height: 24px;
letter-spacing: 0px;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-left {
width: 20px;
height: 24px;
color: rgb(95, 101, 108);
font-family: "Source Han Sans CN";
font-size: 16px;
font-weight: 400;
line-height: 24px;
letter-spacing: 0px;
text-align: center;
}
.item-count {
height: 24px;
width: 92px;
text-align: right;
font-family: "Source Han Sans CN";
color: rgb(95, 101, 108)
}
.item-center {
margin-left: 20px;
width: 425px;
height: 24px;
color: rgb(59, 65, 75);
font-family: "Source Han Sans CN";
font-size: 16px;
font-weight: 400;
line-height: 24px;
letter-spacing: 0px;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.itemBold1 {
color: rgba(206, 79, 81, 1);
font-weight: 700;
}
.item-count {
height: 24px;
width: 92px;
text-align: right;
font-family: "Source Han Sans CN";
color: rgb(95, 101, 108)
}
.itemBold2 {
color: rgba(255, 169, 64, 1);
font-weight: 700;
}
.itemBold1 {
color: rgba(206, 79, 81, 1);
font-weight: 700;
}
.itemBold3 {
color: rgba(255, 197, 61, 1);
font-weight: 700;
}
.itemBold2 {
color: rgba(255, 169, 64, 1);
font-weight: 700;
}
.item-right {
width: 92px;
height: 24px;
color: rgba(95, 101, 108, 1);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 24px;
letter-spacing: 0px;
text-align: right;
cursor: pointer;
.itemBold3 {
color: rgba(255, 197, 61, 1);
font-weight: 700;
}
.item-right {
width: 92px;
height: 24px;
color: rgba(95, 101, 108, 1);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 24px;
letter-spacing: 0px;
text-align: right;
cursor: pointer;
}
}
}
}
......
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 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 = [
'rgba(5, 95, 194, 1)', // #055fc2
......@@ -48,6 +66,7 @@ const getMultiLineChart = (data) => {
return ({
name: item.name,
type: 'line',
smooth: true,
// 新增/优化:面积填充渐变效果
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
......@@ -80,8 +99,9 @@ const getMultiLineChart = (data) => {
}
}
},
/* 顶部预留足够空间:多行图例 + Y 轴标题「数量」在纵轴顶端,避免与图例重合 */
grid: {
top: '15%',
top: '28%',
right: '5%',
bottom: '5%',
left: '5%',
......@@ -89,10 +109,12 @@ const getMultiLineChart = (data) => {
},
legend: {
show: true,
top: 10,
type: 'plain',
data: legendData,
top: 8,
left: 'center',
icon: 'circle',
textStyle: {
textStyle: {
fontFamily: 'Source Han Sans CN', // 字体
fontWeight: 400, // 字重值(Regular对应400)
fontSize: 14, // 字号
......@@ -112,6 +134,22 @@ const getMultiLineChart = (data) => {
yAxis: [
{
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: {
show: true, // 显示网格线
lineStyle: {
......
......@@ -10,7 +10,8 @@ const getPieChart = (data) => {
// left: '15%',
// containLabel: true
// },
radius: [70, 100],
// 使用百分比半径,避免固定像素导致饼图“看起来没铺满容器”
radius: ['45%', '60%'],
height: '100%',
left: 'center',
width: '100%',
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论