提交 835f1f14 authored 作者: 朱政's avatar 朱政

feat:智库bug修复

上级 137f5b94
流水线 #578 已通过 于阶段
in 1 分 38 秒
......@@ -75,7 +75,7 @@ export function getThinkTankReportDomainStats(params) {
export function getThinkTankPolicyIndustry(params) {
return request({
method: 'GET',
url: `/api/thinkTankOverview/policyIndustry/${params.year}`,
url: `/api/thinkTankOverview/policyIndustry`,
params
})
}
......
......@@ -4,6 +4,69 @@ import getQuarterRange from './getQuarterRange';
import * as echarts from 'echarts'
import 'echarts-wordcloud';
import router from '@/router/index'
const LEGEND_ARROW_NAMES = new Set(['__legend_prev__', '__legend_next__'])
const shouldShowLegendPagingArrows = (chart, option) => {
const w = typeof chart?.getWidth === 'function' ? chart.getWidth() : 0
const legendOpt = option?.legend
const legend = Array.isArray(legendOpt) ? legendOpt[0] : legendOpt
if (!legend || legend.type !== 'scroll') return false
const names = Array.isArray(legend.data) ? legend.data : []
if (!names.length) return false
const resolveSize = (val, base) => {
if (val == null) return 0
if (typeof val === 'number' && Number.isFinite(val)) return val
const s = String(val).trim()
if (!s) return 0
if (s.endsWith('%')) {
const n = parseFloat(s.slice(0, -1))
return Number.isFinite(n) ? (base * n) / 100 : 0
}
const n = Number(s)
return Number.isFinite(n) ? n : 0
}
const available = resolveSize(legend.width, w) || Math.floor(w * 0.82)
if (!available || available <= 0) return true
const fontSize = Number(legend?.textStyle?.fontSize || 14)
const fontFamily = legend?.textStyle?.fontFamily || 'Source Han Sans CN'
const fontWeight = legend?.textStyle?.fontWeight || 400
const iconW = Number(legend?.itemWidth || 12)
const gap = 10
const paddingPerItem = iconW + gap + 10
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) return true
ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`
const total = names.reduce((sum, n) => {
const text = String(n ?? '')
const textW = ctx.measureText(text).width || 0
return sum + textW + paddingPerItem
}, 0)
return total > available
}
const applyLegendPagingArrowVisibility = (chart, option) => {
const shouldShow = shouldShowLegendPagingArrows(chart, option)
const graphic = option?.graphic
if (!Array.isArray(graphic) || graphic.length === 0) return
const nextGraphic = graphic.map((g) => {
if (!g || !LEGEND_ARROW_NAMES.has(g.name)) return g
return {
...g,
invisible: !shouldShow,
silent: !shouldShow
}
})
chart.setOption({ graphic: nextGraphic }, false, true)
}
const setChart = (option, chartId, allowClick, selectParam) => {
let chartDom = document.getElementById(chartId);
if (!chartDom) {
......@@ -12,8 +75,39 @@ const setChart = (option, chartId, allowClick, selectParam) => {
chartDom.removeAttribute("_echarts_instance_");
let chart = echarts.init(chartDom);
chart.setOption(option);
// 处理自定义图例分页箭头(左右分布,隐藏页码)
// 约定:graphic 元素 name 为 __legend_prev__ / __legend_next__
chart.on('click', function (params) {
if (params?.componentType !== 'graphic') return;
if (!LEGEND_ARROW_NAMES.has(params?.name)) return;
const opt = chart.getOption?.() || {};
const legend = Array.isArray(opt.legend) ? opt.legend[0] : null;
if (!legend || legend.type !== 'scroll') return;
const dataLen = Array.isArray(legend.data) ? legend.data.length : 0;
if (dataLen <= 0) return;
const cur = Number(legend.scrollDataIndex || 0);
const nextIndex = params.name === '__legend_prev__' ? Math.max(0, cur - 1) : Math.min(dataLen - 1, cur + 1);
if (nextIndex === cur) return;
chart.dispatchAction({
type: 'legendScroll',
scrollDataIndex: nextIndex
});
});
// 初次渲染后判断是否需要显示左右箭头(可一行展示则隐藏)
applyLegendPagingArrowVisibility(chart, option)
if (allowClick) {
chart.on('click', function (params) {
// 图例分页箭头只负责翻页,不走任何跳转
if (params?.componentType === 'graphic' && LEGEND_ARROW_NAMES.has(params?.name)) {
return
}
switch (selectParam.moduleType) {
case '国会法案':
if (selectParam.key === 1) {
......@@ -112,6 +206,7 @@ const setChart = (option, chartId, allowClick, selectParam) => {
// 容器可能受布局/异步渲染影响,强制一次 resize 保证 canvas 与容器一致
setTimeout(() => {
chart.resize();
applyLegendPagingArrowVisibility(chart, option)
}, 0);
return chart;
};
......
......@@ -186,18 +186,26 @@ let leftChart;
const rightChartRef = ref(null);
let rightChart;
const isShowAiLeft = ref(true);
const isShowAiLeft = ref(false);
const aiContentLeft = ref("");
const isLeftInterpretLoading = ref(false);
const leftAiAbortController = ref(null);
const isShowAiRight = ref(true);
const isShowAiRight = ref(false);
const aiContentRight = ref("");
const isRightInterpretLoading = ref(false);
const rightAiAbortController = ref(null);
const handleSwitchAiLeft = (val) => {
isShowAiLeft.value = val;
if (val) {
fetchLeftInterpretation();
} else {
if (leftAiAbortController.value) {
leftAiAbortController.value.abort();
leftAiAbortController.value = null;
}
isLeftInterpretLoading.value = false;
}
};
......@@ -205,6 +213,12 @@ const handleSwitchAiRight = (val) => {
isShowAiRight.value = val;
if (val) {
fetchRightInterpretation();
} else {
if (rightAiAbortController.value) {
rightAiAbortController.value.abort();
rightAiAbortController.value = null;
}
isRightInterpretLoading.value = false;
}
};
......@@ -276,12 +290,17 @@ const fetchLeftInterpretation = async () => {
if (hasValidContent || isLeftInterpretLoading.value) {
return;
}
if (leftAiAbortController.value) {
leftAiAbortController.value.abort();
}
leftAiAbortController.value = new AbortController();
isLeftInterpretLoading.value = true;
aiContentLeft.value = "解读生成中…";
try {
const res = await getChartAnalysis(
{ text: JSON.stringify(payload) },
{
signal: leftAiAbortController.value.signal,
onChunk: (chunk) => {
// 与智库概览「数量变化趋势」一致:按 chunk 增量拼接展示
appendAiInterpretationChunk(aiContentLeft, chunk);
......@@ -292,10 +311,13 @@ const fetchLeftInterpretation = async () => {
// 与智库概览一致:优先用最终「解读」收口;否则保留已拼接内容
aiContentLeft.value = text || aiContentLeft.value || "未返回有效解读内容";
} catch (error) {
if (error?.name !== "AbortError") {
console.error("合作限制政策对比图表解读请求失败", error);
aiContentLeft.value = "解读加载失败";
}
} finally {
isLeftInterpretLoading.value = false;
leftAiAbortController.value = null;
}
};
......@@ -348,12 +370,17 @@ const fetchRightInterpretation = async () => {
if (hasValidContent || isRightInterpretLoading.value) {
return;
}
if (rightAiAbortController.value) {
rightAiAbortController.value.abort();
}
rightAiAbortController.value = new AbortController();
isRightInterpretLoading.value = true;
aiContentRight.value = "解读生成中…";
try {
const res = await getChartAnalysis(
{ text: JSON.stringify(payload) },
{
signal: rightAiAbortController.value.signal,
onChunk: (chunk) => {
appendAiInterpretationChunk(aiContentRight, chunk);
}
......@@ -362,10 +389,13 @@ const fetchRightInterpretation = async () => {
const text = getInterpretationTextFromChartResponse(res);
aiContentRight.value = text || aiContentRight.value || "未返回有效解读内容";
} catch (error) {
if (error?.name !== "AbortError") {
console.error("合作限制领域分布图表解读请求失败", error);
aiContentRight.value = "解读加载失败";
}
} finally {
isRightInterpretLoading.value = false;
rightAiAbortController.value = null;
}
};
......
......@@ -61,17 +61,17 @@
</div>
</div>
</div>
<div class="page">
<div class="count">{{ total }} 项调查</div>
<el-pagination v-model:current-page="currentPage" :page-size="pageSize" :total="total"
layout="prev, pager, next" background @current-change="handlePageChange" />
</div>
</template>
<template v-else>
<div class="right-main-empty">
<el-empty class="right-el-empty" description="暂无数据" :image-size="100" />
</div>
</template>
<div class="page">
<div class="count">共 {{ total }} 项调查</div>
<el-pagination v-model:current-page="currentPage" :page-size="pageSize" :page-count="pageCount"
layout="prev, pager, next" background @current-change="handlePageChange" />
</div>
</div>
</div>
</div>
......@@ -293,6 +293,11 @@ const total = ref(0);
const pageSize = ref(10);
const currentPage = ref(1);
const reslibContainer = ref(null);
const pageCount = computed(() => {
const size = Number(pageSize.value || 10) || 10;
const t = Number(total.value || 0) || 0;
return Math.max(1, Math.ceil(t / size));
});
const getTypeClass = (type) => {
const map = {
......
......@@ -26,27 +26,15 @@
<div class="btn-box">
<div class="translate">
<div class="search-input-wrap" v-if="showSearchInput">
<input
v-model="searchKeywordText"
class="search-input"
placeholder="回车查询"
@keyup.enter="handleSearchInPdf"
/>
<input v-model="searchKeywordText" class="search-input" placeholder="回车查询"
@keyup.enter="handleSearchInPdf" />
<div class="search-match-count">{{ matchInfo.current }}/{{ matchInfo.total }}</div>
<button
class="search-nav-btn"
type="button"
@click="handlePrevMatch"
:disabled="matchInfo.total === 0 || matchInfo.current <= 1"
>
<button class="search-nav-btn" type="button" @click="handlePrevMatch"
:disabled="matchInfo.total === 0 || matchInfo.current <= 1">
上一个
</button>
<button
class="search-nav-btn"
type="button"
@click="handleNextMatch"
:disabled="matchInfo.total === 0 || matchInfo.current >= matchInfo.total"
>
<button class="search-nav-btn" type="button" @click="handleNextMatch"
:disabled="matchInfo.total === 0 || matchInfo.current >= matchInfo.total">
下一个
</button>
</div>
......@@ -54,19 +42,14 @@
<el-switch v-model="valueSwitch" />
</div>
<div class="translate-image">
<img
class="translate-icon"
src="../assets/icon-translation.png"
alt=""
style="
<img class="translate-icon" src="../assets/icon-translation.png" alt="" style="
width: 16px;
height: 16px;
max-width: 16px;
max-height: 16px;
display: block;
object-fit: contain;
"
/>
" />
</div>
<div class="translate-text">{{ "显示原文" }}</div>
</div>
......@@ -83,12 +66,8 @@
<pdf ref="leftPdfRef" :pdfUrl="reportUrlEnWithPage" class="pdf-pane-inner" />
</div>
<div class="pdf-pane-wrap" :class="{ 'is-full': !valueSwitch }" v-if="reportUrlWithPage">
<pdf
:key="`right-pdf-${valueSwitch ? 'split' : 'full'}`"
ref="rightPdfRef"
:pdfUrl="reportUrlWithPage"
class="pdf-pane-inner"
/>
<pdf :key="`right-pdf-${valueSwitch ? 'split' : 'full'}`" ref="rightPdfRef" :pdfUrl="reportUrlWithPage"
class="pdf-pane-inner" />
</div>
</div>
</div>
......@@ -203,7 +182,7 @@ const handleSearchInPdf = async () => {
try {
const { ElMessage } = await import("element-plus");
ElMessage.warning("未找到包含该关键词的页面");
} catch (_) {}
} catch (_) { }
}
};
......@@ -246,7 +225,7 @@ const handleDownload = async () => {
try {
const { ElMessage } = await import("element-plus");
ElMessage.warning("暂无下载链接");
} catch (_) {}
} catch (_) { }
return;
}
const baseName = (thinkInfo.value?.name || "报告原文").replace(/[/\\?%*:|"<>]/g, "-");
......@@ -659,6 +638,7 @@ onMounted(async () => {
display: flex;
gap: 8px;
cursor: pointer;
.icon {
width: 16px;
height: 16px;
......
......@@ -171,6 +171,10 @@ const options = [
];
/** value 须与 v-model 类型一致(数字),否则 el-select 无法匹配 label,会显示成「2025」而非「2025年」 */
const options1 = [
{
value: 2026,
label: "2026年"
},
{
value: 2025,
label: "2025年"
......@@ -719,11 +723,11 @@ let rightChart1;
let leftSankey;
let boxplotChart;
// ------- AI 解读(刷新后默认展开,行为对齐智库概览) -------
const isShowAiContentLeft1 = ref(true);
const isShowAiContentLeft2 = ref(true);
const isShowAiContentRight1 = ref(true);
const isShowAiContentRight2 = ref(true);
// ------- AI 解读(默认仅展示 AiButton,悬停后再请求 AI) -------
const isShowAiContentLeft1 = ref(false);
const isShowAiContentLeft2 = ref(false);
const isShowAiContentRight1 = ref(false);
const isShowAiContentRight2 = ref(false);
const aiContentLeft1 = ref("");
const aiContentLeft2 = ref("");
......@@ -735,6 +739,11 @@ const isAiLoadingLeft2 = ref(false);
const isAiLoadingRight1 = ref(false);
const isAiLoadingRight2 = ref(false);
const left1AiAbortController = ref(null);
const left2AiAbortController = ref(null);
const right1AiAbortController = ref(null);
const right2AiAbortController = ref(null);
const AI_LOADING_TEXT = "解读生成中…";
// 用于保证“切换筛选后只写入最新一次解读结果”
const left1AiSeq = ref(0);
......@@ -767,7 +776,13 @@ const getInterpretationTextFromChartResponse = (res) => {
);
};
const fetchChartInterpretationOnce = async (payload, targetRef, loadingRef, aiSeqRef) => {
const fetchChartInterpretationOnce = async (
payload,
targetRef,
loadingRef,
aiSeqRef,
abortControllerRef
) => {
if (loadingRef.value) return;
const hasValidContent =
......@@ -778,12 +793,19 @@ const fetchChartInterpretationOnce = async (payload, targetRef, loadingRef, aiSe
if (hasValidContent) return;
const localSeq = aiSeqRef.value;
if (abortControllerRef?.value) {
abortControllerRef.value.abort();
}
if (abortControllerRef) {
abortControllerRef.value = new AbortController();
}
loadingRef.value = true;
targetRef.value = AI_LOADING_TEXT;
try {
const res = await getChartAnalysis(
{ text: JSON.stringify(payload) },
{
...(abortControllerRef?.value?.signal ? { signal: abortControllerRef.value.signal } : {}),
onChunk: (chunk) => {
if (aiSeqRef.value !== localSeq) return;
appendAiInterpretationChunk(targetRef, chunk, AI_LOADING_TEXT);
......@@ -796,10 +818,13 @@ const fetchChartInterpretationOnce = async (payload, targetRef, loadingRef, aiSe
targetRef.value = text || targetRef.value || "未返回有效解读内容";
} catch (e) {
if (aiSeqRef.value !== localSeq) return;
if (e?.name !== "AbortError") {
console.error("图表解读请求失败", e);
targetRef.value = "解读加载失败";
}
} finally {
if (aiSeqRef.value === localSeq) loadingRef.value = false;
if (abortControllerRef) abortControllerRef.value = null;
}
};
......@@ -842,43 +867,71 @@ const buildPayloadRight2 = () => {
const handleSwitchAiLeft1 = async (val) => {
isShowAiContentLeft1.value = val;
if (!val) return;
if (!val) {
if (left1AiAbortController.value) {
left1AiAbortController.value.abort();
left1AiAbortController.value = null;
}
isAiLoadingLeft1.value = false;
return;
}
const payload = buildPayloadLeft1();
if (!payload) {
aiContentLeft1.value = "暂无图表数据";
return;
}
await fetchChartInterpretationOnce(payload, aiContentLeft1, isAiLoadingLeft1, left1AiSeq);
await fetchChartInterpretationOnce(payload, aiContentLeft1, isAiLoadingLeft1, left1AiSeq, left1AiAbortController);
};
const handleSwitchAiLeft2 = async (val) => {
isShowAiContentLeft2.value = val;
if (!val) return;
if (!val) {
if (left2AiAbortController.value) {
left2AiAbortController.value.abort();
left2AiAbortController.value = null;
}
isAiLoadingLeft2.value = false;
return;
}
const payload = buildPayloadLeft2();
if (!payload) {
aiContentLeft2.value = "暂无图表数据";
return;
}
await fetchChartInterpretationOnce(payload, aiContentLeft2, isAiLoadingLeft2, left2AiSeq);
await fetchChartInterpretationOnce(payload, aiContentLeft2, isAiLoadingLeft2, left2AiSeq, left2AiAbortController);
};
const handleSwitchAiRight1 = async (val) => {
isShowAiContentRight1.value = val;
if (!val) return;
if (!val) {
if (right1AiAbortController.value) {
right1AiAbortController.value.abort();
right1AiAbortController.value = null;
}
isAiLoadingRight1.value = false;
return;
}
const payload = buildPayloadRight1();
if (!payload) {
aiContentRight1.value = "暂无图表数据";
return;
}
await fetchChartInterpretationOnce(payload, aiContentRight1, isAiLoadingRight1, right1AiSeq);
await fetchChartInterpretationOnce(payload, aiContentRight1, isAiLoadingRight1, right1AiSeq, right1AiAbortController);
};
const handleSwitchAiRight2 = async (val) => {
isShowAiContentRight2.value = val;
if (!val) return;
if (!val) {
if (right2AiAbortController.value) {
right2AiAbortController.value.abort();
right2AiAbortController.value = null;
}
isAiLoadingRight2.value = false;
return;
}
const payload = buildPayloadRight2();
if (!payload) {
aiContentRight2.value = "暂无图表数据";
return;
}
await fetchChartInterpretationOnce(payload, aiContentRight2, isAiLoadingRight2, right2AiSeq);
await fetchChartInterpretationOnce(payload, aiContentRight2, isAiLoadingRight2, right2AiSeq, right2AiAbortController);
};
......@@ -1136,21 +1189,15 @@ const initLeftSankey = (data) => {
// };
onMounted(() => {
// 刷新后 AiPane 默认展开:先给出“解读生成中…”占位,再在数据到位后触发解读请求
aiContentLeft1.value = "解读生成中…";
aiContentLeft2.value = "解读生成中…";
aiContentRight1.value = "解读生成中…";
aiContentRight2.value = "解读生成中…";
// 先拉数据;每块数据到位后立即触发一次 AI 解读(不必等其它块完成)
void handleGetFundField().then(() => handleSwitchAiLeft1(true));
// 仅拉取图表数据;AI 解读仅在用户悬停打开面板时触发
void handleGetFundField();
void handleFindCountryProjectAreaList();
void handlegetCountryFundingChange().then(() => handleSwitchAiRight1(true));
void handlegetCountryFundingChange();
void handlegetCountryFundProjectChange();
void handleGetOrgFundsArea().then(() => handleSwitchAiLeft2(true));
void handlegetOrgFundStrength().then(() => handleSwitchAiRight2(true));
void handleGetOrgFundsArea();
void handlegetOrgFundStrength();
});
// onBeforeUnmount(() => {
// window.removeEventListener("resize", handleResize);
......
......@@ -166,6 +166,10 @@ const handleTimeGroupChange = (val) => {
};
const pubTimeList = ref([
{
id: 2026,
name: "2026年"
},
{
id: 2025,
name: "2025年"
......@@ -192,13 +196,13 @@ const pubTimeList = ref([
}
]);
/** 选择「全部时间」时,yearlist 传 2000~2025 逐年 */
/** 选择「全部时间」时,yearlist 传 2000~2026 逐年 */
const YEAR_ALL_RANGE_START = 2000;
const YEAR_ALL_RANGE_END = 2025;
const YEAR_ALL_RANGE_END = 2026;
const buildYearlistForRequest = (selectedTimeModel) => {
const strippedTime = stripAllTimeForRequest(selectedTimeModel);
// 仅勾选「全部时间」、未选具体年份时,传 2000~2025 逐年
// 仅勾选「全部时间」、未选具体年份时,传 2000~2026 逐年
if (strippedTime.length === 0) {
const out = [];
for (let y = YEAR_ALL_RANGE_START; y <= YEAR_ALL_RANGE_END; y += 1) {
......
......@@ -136,7 +136,7 @@
<div class="box5-footer">
<TipTab :text="REPORT_ANALYSIS_TIP_BOX5" />
</div>
<div class="ai-wrap" @mouseenter="handleSwitchAiContentShowBox5(true)">
<div class="ai-wrap" v-if="!isShowAiContentBox5" @mouseenter="handleSwitchAiContentShowBox5(true)">
<AiButton />
</div>
......@@ -305,14 +305,21 @@ const handleGetThinkTankHearingInfo = async () => {
};
const REPORT_ANALYSIS_TIP_BOX5 =
"国会听证会关键词云,数据来源:美国兰德公司官网";
// 刷新后默认展示「报告关键词云」AI 总结
const isShowAiContentBox5 = ref(true);
// 默认仅展示 AiButton,悬停后再请求 AI
const isShowAiContentBox5 = ref(false);
const aiContentBox5 = ref("");
const isBox5InterpretLoading = ref(false);
const box5AiAbortController = ref(null);
const handleSwitchAiContentShowBox5 = (val) => {
isShowAiContentBox5.value = val;
if (val) {
fetchBox5ChartInterpretation();
} else {
if (box5AiAbortController.value) {
box5AiAbortController.value.abort();
box5AiAbortController.value = null;
}
isBox5InterpretLoading.value = false;
}
};
......@@ -510,10 +517,7 @@ const handleGetThinkTankReportIndustryCloud = async () => {
if (data.length) {
box5WordCloudKey.value += 1;
}
// 刷新后默认展开 AI:数据就绪即触发解读
if (isShowAiContentBox5.value) {
fetchBox5ChartInterpretation();
}
// 仅在用户打开 AI 面板时才请求解读
} else {
box5Data.value = [];
}
......@@ -663,6 +667,10 @@ const fetchBox5ChartInterpretation = async () => {
if (hasValidContent || isBox5InterpretLoading.value) {
return;
}
if (box5AiAbortController.value) {
box5AiAbortController.value.abort();
}
box5AiAbortController.value = new AbortController();
isBox5InterpretLoading.value = true;
aiContentBox5.value = "解读生成中…";
const chartPayload = {
......@@ -677,6 +685,7 @@ const fetchBox5ChartInterpretation = async () => {
const res = await getChartAnalysis(
{ text: JSON.stringify(chartPayload) },
{
signal: box5AiAbortController.value.signal,
onChunk: chunk => {
appendAiInterpretationChunk(aiContentBox5, chunk);
}
......@@ -685,10 +694,13 @@ const fetchBox5ChartInterpretation = async () => {
const text = getInterpretationTextFromChartResponse(res);
aiContentBox5.value = text || aiContentBox5.value || "未返回有效解读内容";
} catch (error) {
if (error?.name !== "AbortError") {
console.error("报告关键词云图表解读请求失败", error);
aiContentBox5.value = "解读加载失败";
}
} finally {
isBox5InterpretLoading.value = false;
box5AiAbortController.value = null;
}
};
......
......@@ -79,7 +79,7 @@
<div class="box5-footer">
<TipTab :text="REPORT_ANALYSIS_TIP_BOX5" />
</div>
<div class="ai-wrap" @mouseenter="handleSwitchAiContentShowBox5(true)">
<div class="ai-wrap" v-if="!isShowAiContentBox5" @mouseenter="handleSwitchAiContentShowBox5(true)">
<AiButton />
</div>
......@@ -259,14 +259,21 @@ const props = defineProps({
});
const REPORT_ANALYSIS_TIP_BOX5 =
"智库报告关键词云,数据来源:美国兰德公司官网";
// 刷新后默认展示「报告关键词云」AI 总结
const isShowAiContentBox5 = ref(true);
// 默认仅展示 AiButton,悬停后再请求 AI
const isShowAiContentBox5 = ref(false);
const aiContentBox5 = ref("");
const isBox5InterpretLoading = ref(false);
const box5AiAbortController = ref(null);
const handleSwitchAiContentShowBox5 = (val) => {
isShowAiContentBox5.value = val;
if (val) {
fetchBox5ChartInterpretation();
} else {
if (box5AiAbortController.value) {
box5AiAbortController.value.abort();
box5AiAbortController.value = null;
}
isBox5InterpretLoading.value = false;
}
};
......@@ -463,10 +470,7 @@ const handleGetThinkTankReportIndustryCloud = async () => {
if (data.length) {
box5WordCloudKey.value += 1;
}
// 刷新后默认展开 AI:数据就绪即触发解读
if (isShowAiContentBox5.value) {
fetchBox5ChartInterpretation();
}
// 仅在用户打开 AI 面板时才请求解读
} else {
box5Data.value = [];
}
......@@ -617,6 +621,10 @@ const fetchBox5ChartInterpretation = async () => {
if (hasValidContent || isBox5InterpretLoading.value) {
return;
}
if (box5AiAbortController.value) {
box5AiAbortController.value.abort();
}
box5AiAbortController.value = new AbortController();
isBox5InterpretLoading.value = true;
aiContentBox5.value = "解读生成中…";
const chartPayload = {
......@@ -631,6 +639,7 @@ const fetchBox5ChartInterpretation = async () => {
const res = await getChartAnalysis(
{ text: JSON.stringify(chartPayload) },
{
signal: box5AiAbortController.value.signal,
onChunk: chunk => {
appendAiInterpretationChunk(aiContentBox5, chunk);
}
......@@ -639,10 +648,13 @@ const fetchBox5ChartInterpretation = async () => {
const text = getInterpretationTextFromChartResponse(res);
aiContentBox5.value = text || aiContentBox5.value || "未返回有效解读内容";
} catch (error) {
if (error?.name !== "AbortError") {
console.error("报告关键词云图表解读请求失败", error);
aiContentBox5.value = "解读加载失败";
}
} finally {
isBox5InterpretLoading.value = false;
box5AiAbortController.value = null;
}
};
......
......@@ -43,7 +43,7 @@
<div class="box1-footer">
<TipTab :text="REPORT_ANALYSIS_TIP_BOX5" />
</div>
<div class="ai-wrap" @mouseenter="handleSwitchAiContentShowBox5(true)">
<div class="ai-wrap" v-if="!isShowAiContentBox5" @mouseenter="handleSwitchAiContentShowBox5(true)">
<AiButton />
</div>
......@@ -185,14 +185,21 @@ const applySurveyProjectDocumentTitle = (title) => {
};
const REPORT_ANALYSIS_TIP_BOX5 =
"调查项目关键词云,数据来源:美国兰德公司官网";
// 刷新后默认展示「报告关键词云」AI 总结
const isShowAiContentBox5 = ref(true);
// 默认仅展示 AiButton,悬停后再请求 AI
const isShowAiContentBox5 = ref(false);
const aiContentBox5 = ref("");
const isBox5InterpretLoading = ref(false);
const box5AiAbortController = ref(null);
const handleSwitchAiContentShowBox5 = (val) => {
isShowAiContentBox5.value = val;
if (val) {
fetchBox5ChartInterpretation();
} else {
if (box5AiAbortController.value) {
box5AiAbortController.value.abort();
box5AiAbortController.value = null;
}
isBox5InterpretLoading.value = false;
}
};
const searchOpinions = ref('');
......@@ -403,10 +410,7 @@ const handleGetThinkTankReportIndustryCloud = async () => {
if (data.length) {
box5WordCloudKey.value += 1;
}
// 刷新后默认展开 AI:数据就绪即触发解读
if (isShowAiContentBox5.value) {
fetchBox5ChartInterpretation();
}
// 仅在用户打开 AI 面板时才请求解读
} else {
box5Data.value = [];
}
......@@ -548,6 +552,10 @@ const fetchBox5ChartInterpretation = async () => {
if (hasValidContent || isBox5InterpretLoading.value) {
return;
}
if (box5AiAbortController.value) {
box5AiAbortController.value.abort();
}
box5AiAbortController.value = new AbortController();
isBox5InterpretLoading.value = true;
aiContentBox5.value = "解读生成中…";
const chartPayload = {
......@@ -562,6 +570,7 @@ const fetchBox5ChartInterpretation = async () => {
const res = await getChartAnalysis(
{ text: JSON.stringify(chartPayload) },
{
signal: box5AiAbortController.value.signal,
onChunk: chunk => {
appendAiInterpretationChunk(aiContentBox5, chunk);
}
......@@ -570,10 +579,13 @@ const fetchBox5ChartInterpretation = async () => {
const text = getInterpretationTextFromChartResponse(res);
aiContentBox5.value = text || aiContentBox5.value || "未返回有效解读内容";
} catch (error) {
if (error?.name !== "AbortError") {
console.error("报告关键词云图表解读请求失败", error);
aiContentBox5.value = "解读加载失败";
}
} finally {
isBox5InterpretLoading.value = false;
box5AiAbortController.value = null;
}
};
......
import * as echarts from 'echarts'
import { MUTICHARTCOLORS } from "@/common/constant.js";
// 按 AreaTag 的颜色规则映射到折线图配色(取 tag 的文字色)
const AREA_TAG_COLOR_BY_NAME = {
'人工智能': 'rgba(245, 34, 45, 1)', // tag1
'生物科技': 'rgba(19, 168, 168, 1)', // tag2
'新一代通信网络': 'rgba(5, 95, 194, 1)', // tag3
// 兼容常见写法
'通信网络': 'rgba(5, 95, 194, 1)',
'量子科技': 'rgba(114, 46, 209, 1)', // tag4
'新能源': 'rgba(82, 196, 26, 1)', // tag5
'集成电路': 'rgba(22, 119, 255, 1)', // tag6
'海洋': 'rgba(15, 120, 199, 1)', // tag7
'先进制造': 'rgba(250, 173, 20, 1)', // tag8
'新材料': 'rgba(250, 140, 22, 1)', // tag9
'航空航天': 'rgba(47, 84, 235, 1)', // tag10
'太空': 'rgba(47, 84, 235, 1)', // tag11
'深海': 'rgba(73, 104, 161, 1)', // tag12
'极地': 'rgba(133, 165, 255, 1)', // tag13
'核': 'rgba(250, 84, 28, 1)', // tag14
'其他': 'rgba(82, 196, 26, 1)' // tag15
}
const fallbackColorList = [
'rgba(5, 95, 194, 1)',
'rgba(245, 34, 45, 1)',
'rgba(19, 168, 168, 1)',
'rgba(250, 140, 22, 1)',
'rgba(114, 46, 209, 1)',
'rgba(82, 196, 26, 1)',
'rgba(22, 119, 255, 1)',
'rgba(250, 84, 28, 1)',
'rgba(47, 84, 235, 1)',
'rgba(133, 165, 255, 1)'
]
const parseRgba = (colorStr) => {
const match = colorStr.match(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*(\d+(\.\d+)?)\)/)
if (match) {
const parseHexToRgb = (hex) => {
const h = String(hex || '').replace('#', '').trim()
if (h.length !== 6) return { r: 0, g: 0, b: 0 }
return {
r: parseInt(match[1], 10),
g: parseInt(match[2], 10),
b: parseInt(match[3], 10),
a: parseFloat(match[4])
}
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
}
return { r: 0, g: 0, b: 0, a: 1 }
}
/**
......@@ -55,22 +19,14 @@ const getMultiLineChart = (chartInput) => {
const series = chartInput.data || []
const allNames = series.map((item) => item.name)
const lineSize = Math.ceil(allNames.length / 3)
const legendLine1 = allNames.slice(0, lineSize)
const legendLine2 = allNames.slice(lineSize, lineSize * 2)
const legendLine3 = allNames.slice(lineSize * 2)
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 ||
AREA_TAG_COLOR_BY_NAME[item.name] ||
fallbackColorList[index % fallbackColorList.length] ||
`rgba(${Math.floor(Math.random() * 256)}, ${Math.floor(Math.random() * 256)}, ${Math.floor(Math.random() * 256)}, 1)`
const { r, g, b } = parseRgba(baseColor)
const baseColor = MUTICHARTCOLORS[index % MUTICHARTCOLORS.length] || '#055FC2'
const { r, g, b } = parseHexToRgb(baseColor)
return {
name: item.name,
......@@ -102,39 +58,31 @@ const getMultiLineChart = (chartInput) => {
},
/* 贴满 #box3Chart:四边 0,由 containLabel 在网格内为轴文字留位,避免左侧/底部大块留白 */
grid: {
top: 92,
top: 60,
right: 10,
bottom: 0,
left: 20,
containLabel: true
},
legend: [
{
legend: {
show: true,
type: 'plain',
data: legendLine1,
top: 4,
left: 'center',
type: 'scroll',
orient: 'horizontal',
left: 8,
top: 6,
width: '95%',
height: 24,
padding: [0, 24, 0, 24],
icon: 'circle',
textStyle: {
fontFamily: 'Source Han Sans CN',
fontWeight: 400,
fontSize: 14,
lineHeight: 24,
letterSpacing: 0,
align: 'left',
color: 'rgb(95, 101, 108)'
},
itemWidth: 12,
itemHeight: 12
},
{
show: legendLine2.length > 0,
type: 'plain',
data: legendLine2,
top: 30,
left: 'center',
icon: 'circle',
itemHeight: 12,
data: allNames,
// 隐藏内置分页按钮与页码(使用 graphic 自定义左右箭头)
pageButtonPosition: 'end',
pageIconSize: 0,
pageButtonGap: 0,
pageFormatter: () => '',
pageTextStyle: { fontSize: 0, color: 'transparent' },
textStyle: {
fontFamily: 'Source Han Sans CN',
fontWeight: 400,
......@@ -143,28 +91,32 @@ const getMultiLineChart = (chartInput) => {
letterSpacing: 0,
align: 'left',
color: 'rgb(95, 101, 108)'
}
},
itemWidth: 12,
itemHeight: 12
},
graphic: [
{
show: legendLine3.length > 0,
type: 'plain',
data: legendLine3,
top: 56,
left: 'center',
icon: 'circle',
textStyle: {
fontFamily: 'Source Han Sans CN',
fontWeight: 400,
fontSize: 14,
lineHeight: 24,
letterSpacing: 0,
align: 'left',
color: 'rgb(95, 101, 108)'
type: 'polygon',
name: '__legend_prev__',
left: 10,
top: 14,
shape: { points: [[8, 0], [0, 6], [8, 12]] },
style: { fill: 'rgb(95, 101, 108)' },
cursor: 'pointer',
tooltip: { show: false },
silent: false,
z: 100
},
itemWidth: 12,
itemHeight: 12
{
type: 'polygon',
name: '__legend_next__',
right: 10,
top: 14,
shape: { points: [[0, 0], [8, 6], [0, 12]] },
style: { fill: 'rgb(95, 101, 108)' },
cursor: 'pointer',
tooltip: { show: false },
silent: false,
z: 100
}
],
// 不使用全局 color,改为每条 series 自己定色(与 AreaTag 一致)
......
import { MUTICHARTCOLORS } from "@/common/constant.js";
const getPieChart = (data) => {
const seriesData = (Array.isArray(data) ? data : []).map((d) => {
const color = d?.color
if (!color) return d
const seriesData = (Array.isArray(data) ? data : []).map((d, index) => {
const color = MUTICHARTCOLORS[index % MUTICHARTCOLORS.length]
return {
...d,
itemStyle: { ...(d.itemStyle || {}), color },
itemStyle: { ...(d?.itemStyle || {}), color },
// “飞线”(labelLine)跟随领域色
labelLine: {
...(d.labelLine || {}),
lineStyle: { ...(d.labelLine?.lineStyle || {}), color }
...(d?.labelLine || {}),
lineStyle: { ...(d?.labelLine?.lineStyle || {}), color }
}
}
})
......@@ -30,7 +31,11 @@ const getPieChart = (data) => {
formatter: params => {
const name = params.name || "";
const value = Number(params.value ?? 0) || 0;
const percent = params.percent != null ? Number(params.percent).toFixed(2) : "0.00";
const rawPercent =
params?.data?.percent != null
? params.data.percent
: params.percent;
const percent = rawPercent != null ? Number(rawPercent).toFixed(2) : "0.00";
return `{name|${name}}\n{time|${value}${percent}%}`;
},
......
......@@ -72,8 +72,9 @@
</div> -->
</div>
</div>
<div class="right">
<div class="right" v-loading="loading">
<div class="card-box">
<template v-if="hasData">
<div class="card-content">
<div v-for="(item, index) in hearingData" :key="item.id">
<div class="card-item">
......@@ -87,7 +88,8 @@
<span v-html="highlightText(item.time + ' · ' + item.content)"></span>
<img src="../images/image open.png" alt="open icon" class="card-open-image" />
</div>
<div class="card-item-category">
<div class="card-item-category"
v-if="Array.isArray(item.category) && item.category.some(v => String(v || '').trim())">
<AreaTag v-for="(val, idx) in item.category" :key="idx" :tagName="val" />
</div>
</div>
......@@ -97,13 +99,19 @@
</div>
</div>
</template>
<template v-else-if="!loading">
<div class="right-empty">
<el-empty class="right-el-empty" description="暂无数据" :image-size="100" />
</div>
</template>
</div>
<div class="right-footer">
<div class="info">
共 {{ total }} 篇国会听证会
</div>
<div class="page-box">
<el-pagination :page-size="10" background layout="prev, pager, next" :total="total"
<el-pagination :page-size="10" :page-count="pageCount" background layout="prev, pager, next"
@current-change="handleCurrentChange" :current-page="currentPage" />
</div>
</div>
......@@ -169,6 +177,10 @@ const props = defineProps({
searchKeyword: {
type: String,
default: ""
},
loading: {
type: Boolean,
default: false
}
});
......@@ -181,6 +193,12 @@ const emit = defineEmits([
// 解构 props,保持模板里变量名不变
const { researchTypeList, researchTimeList, curFooterList, total, currentPage, researchHearingList, hearingData, selectedYear } = toRefs(props);
const hasData = computed(() => Array.isArray(hearingData.value) && hearingData.value.length > 0);
const pageCount = computed(() => {
const size = 10;
const t = Number(total.value || 0) || 0;
return Math.max(1, Math.ceil(t / size));
});
const pageSize = 10;
function getDateYearsAgo(years) {
......@@ -488,8 +506,11 @@ const handleToReportDetail = item => {
.right {
width: 1224px;
display: flex;
flex-direction: column;
.card-box {
flex: 1;
......@@ -498,6 +519,15 @@ const handleToReportDetail = item => {
background: rgba(255, 255, 255, 1);
.right-empty {
width: 100%;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
box-sizing: border-box;
border: 1px solid rgba(234, 236, 238, 1);
border-radius: 10px;
......
......@@ -52,8 +52,9 @@
</div> -->
</div>
</div>
<div class="right">
<div class="right" v-loading="loading">
<div class="card-box">
<template v-if="hasData">
<div class="footer-card" v-for="(item, index) in curFooterList" :key="index"
@click="handleToReportDetail(item)">
<div class="footer-card-top">
......@@ -67,13 +68,19 @@
<div class="from">{{ item.thinktankName }}</div>
</div>
</div>
</template>
<template v-else-if="!loading">
<div class="right-empty">
<el-empty class="right-el-empty" description="暂无数据" :image-size="100" />
</div>
</template>
</div>
<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="12" :page-count="pageCount" background layout="prev, pager, next"
@current-change="handleCurrentChange" :current-page="currentPage" />
</div>
</div>
......@@ -81,7 +88,7 @@
</div>
</template>
<script setup>
import { ref, toRefs, watch } from "vue";
import { ref, toRefs, watch, computed } from "vue";
import {
RESOURCE_FILTER_ALL_AREA,
RESOURCE_FILTER_ALL_TIME,
......@@ -126,6 +133,10 @@ const props = defineProps({
searchKeyword: {
type: String,
default: ""
},
loading: {
type: Boolean,
default: false
}
});
......@@ -138,6 +149,12 @@ const emit = defineEmits([
// 解构 props,保持模板里变量名不变
const { researchTypeList, researchTimeList, curFooterList, total, currentPage } = toRefs(props);
const hasData = computed(() => Array.isArray(curFooterList.value) && curFooterList.value.length > 0);
const pageCount = computed(() => {
const size = 12;
const t = Number(total.value || 0) || 0;
return Math.max(1, Math.ceil(t / size));
});
const selectedResearchIds = ref([RESOURCE_FILTER_ALL_AREA]);
const selectedResearchTimeIds = ref([RESOURCE_FILTER_ALL_TIME]);
......@@ -360,15 +377,27 @@ const handleToReportDetail = item => {
.right {
width: 1284px;
display: flex;
flex-direction: column;
.card-box {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 13px;
.right-empty {
width: 100%;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.footer-card {
width: 398px;
height: 300px;
......
......@@ -52,8 +52,9 @@
</div> -->
</div>
</div>
<div class="right">
<div class="right" v-loading="loading">
<div class="card-box">
<template v-if="hasData">
<div class="footer-card" v-for="(item, index) in curFooterList" :key="index"
@click="handleToReportDetail(item)">
<div class="footer-card-top">
......@@ -67,13 +68,19 @@
<div class="from">{{ item.thinkTankName }}</div>
</div>
</div>
</template>
<template v-else-if="!loading">
<div class="right-empty">
<el-empty class="right-el-empty" description="暂无数据" :image-size="100" />
</div>
</template>
</div>
<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="12" :page-count="pageCount" background layout="prev, pager, next"
@current-change="handleCurrentChange" :current-page="currentPage" />
</div>
</div>
......@@ -81,7 +88,7 @@
</div>
</template>
<script setup>
import { ref, toRefs, watch } from "vue";
import { ref, toRefs, watch, computed } from "vue";
import {
RESOURCE_FILTER_ALL_AREA,
RESOURCE_FILTER_ALL_TIME,
......@@ -126,6 +133,10 @@ const props = defineProps({
searchKeyword: {
type: String,
default: ""
},
loading: {
type: Boolean,
default: false
}
});
......@@ -138,6 +149,12 @@ const emit = defineEmits([
// 解构 props,保持模板里变量名不变
const { researchTypeList, researchTimeList, curFooterList, total, currentPage } = toRefs(props);
const hasData = computed(() => Array.isArray(curFooterList.value) && curFooterList.value.length > 0);
const pageCount = computed(() => {
const size = 12;
const t = Number(total.value || 0) || 0;
return Math.max(1, Math.ceil(t / size));
});
const selectedResearchIds = ref([RESOURCE_FILTER_ALL_AREA]);
const selectedResearchTimeIds = ref([RESOURCE_FILTER_ALL_TIME]);
......@@ -360,15 +377,28 @@ const handleToReportDetail = item => {
}
.right {
width: 1284px;
display: flex;
flex-direction: column;
.card-box {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 13px;
.right-empty {
width: 100%;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.footer-card {
width: 398px;
height: 300px;
......
......@@ -53,18 +53,20 @@
<ThinkTankReport v-if="isThinkTankReport" :research-type-list="researchTypeList"
:research-time-list="researchTimeList" :key="`智库报告-${tabResetKey}`" :selected-filters="selectedFilters"
:cur-footer-list="curFooterList" :total="total" :current-page="currentPage" :search-keyword="searchReport"
:loading="isThinkTankReportLoading"
@update:selected-filters="handleSelectedFiltersUpdate"
@filter-change="(payload) => handleGetThinkDynamicsReport(payload)" @page-change="handleCurrentChange"
@report-click="handleToReportDetail" />
<CongressHearing v-else-if="isCongressHearing" :research-type-list="researchTypeList"
:research-time-list="researchTimeList" :key="`国会听证会-${tabResetKey}`" :research-hearing-list="researchHearingList"
:selected-filters="selectedFilters" :selected-year="selectedYear" :total="total" :current-page="currentPage"
:search-keyword="searchReport" :hearing-data="hearingData" @update:selected-filters="handleSelectedFiltersUpdate"
:search-keyword="searchReport" :hearing-data="hearingData" :loading="isCongressHearingLoading"
@update:selected-filters="handleSelectedFiltersUpdate"
@filter-change="(payload) => handleGetThinkDynamicsReport(payload)" @page-change="handleCurrentChange"
@report-click="handleToHearingDetail" />
<SurveyForm v-else-if="isSurveyForm" :research-type-list="researchTypeList" :research-time-list="researchTimeList"
:key="`调查项目-${tabResetKey}`" :selected-filters="selectedFilters" :cur-footer-list="curFooterProjectList"
:total="total" :current-page="currentPage" :search-keyword="searchReport"
:total="total" :current-page="currentPage" :search-keyword="searchReport" :loading="isSurveyFormLoading"
@update:selected-filters="handleSelectedFiltersUpdate"
@filter-change="(payload) => handleGetThinkDynamicsReport(payload)" @page-change="handleCurrentChange"
@report-click="handleToProjectDetail" />
......@@ -108,6 +110,10 @@ const isThinkTankReport = ref(true);
const isSurveyForm = ref(false);
const isCongressHearing = ref(false);
const searchReport = ref('')
// 智库详情-智库动态:右侧列表 loading(居中显示)
const isThinkTankReportLoading = ref(false);
const isSurveyFormLoading = ref(false);
const isCongressHearingLoading = ref(false);
const handleToReportDetail = (item) => {
window.sessionStorage.setItem('curTabName', item.name)
......@@ -248,6 +254,10 @@ const handleChooseType = async (type) => {
await handleGetThinkDynamicsReport()
}
const researchTimeList = ref([
{
id: '2026年',
name: '2026年',
},
{
id: '2025年',
name: '2025年',
......@@ -395,6 +405,11 @@ const handleGetThinkDynamicsReport = async (payload) => {
return;
}
try {
// 仅当前 tab 显示 loading,其它置 false,避免切换后残留
isThinkTankReportLoading.value = isThinkTankReport.value;
isSurveyFormLoading.value = isSurveyForm.value;
isCongressHearingLoading.value = isCongressHearing.value;
const strippedTime = stripAllTimeForRequest(nextFilters.researchTimeIds || []);
const allTimeIds = (researchTimeList.value || []).map((x) => x.id);
const { startDate, endDate } = getResourceLibraryReportDateRangeFromTimeSelection(
......@@ -484,6 +499,10 @@ const handleGetThinkDynamicsReport = async (payload) => {
}
} catch (error) {
console.error("获取智库动态报告error", error);
} finally {
isThinkTankReportLoading.value = false;
isSurveyFormLoading.value = false;
isCongressHearingLoading.value = false;
}
};
......
import { MUTICHARTCOLORS } from "@/common/constant.js";
const getPieChart = (data) => {
let option = {
series: [
......@@ -53,7 +55,10 @@ const getPieChart = (data) => {
labelLinePoints: points
};
},
data: data
data: (Array.isArray(data) ? data : []).map((d, index) => ({
...d,
itemStyle: { ...(d?.itemStyle || {}), color: d?.color || MUTICHARTCOLORS[index % MUTICHARTCOLORS.length] }
}))
}]
}
return option
......
import { MUTICHARTCOLORS } from "@/common/constant.js";
const getTreeMapChart = (treemapData) => {
const list = Array.isArray(treemapData) ? treemapData : []
const dataWithColors = list.map((node, idx) => ({
...node,
itemStyle: {
...(node?.itemStyle || {}),
color: node?.itemStyle?.color || MUTICHARTCOLORS[idx % MUTICHARTCOLORS.length]
}
}))
const option = {
tooltip: {
trigger: 'item',
......@@ -14,7 +25,7 @@ const getTreeMapChart = (treemapData) => {
series: [
{
type: 'treemap',
data: treemapData,
data: dataWithColors,
roam: false,
nodeClick: false,
breadcrumb: {
......
......@@ -36,8 +36,9 @@
</div>
</div>
<div class="right">
<div class="right" v-loading="loading">
<div class="card-box">
<template v-if="hasData">
<div class="footer-card" v-for="(item, index) in curFooterList" :key="index"
@click="emit('report-click', item)">
<div class="footer-card-top">
......@@ -51,11 +52,17 @@
<div class="from">{{ item.thinkTankName }}</div>
</div>
</div>
</template>
<template v-else-if="!loading">
<div class="right-empty">
<el-empty class="right-el-empty" description="暂无数据" :image-size="100" />
</div>
</template>
</div>
<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="12" :page-count="pageCount" background layout="prev, pager, next"
@current-change="emit('page-change', $event)" :current-page="currentPage" />
</div>
</div>
......@@ -64,20 +71,22 @@
</template>
<script setup>
import { computed } from "vue";
import {
RESOURCE_FILTER_ALL_AREA,
RESOURCE_FILTER_ALL_TIME,
normalizeExclusiveAllOption
} from "../utils/resourceLibraryFilters";
defineProps({
const props = defineProps({
areaList: { type: Array, default: () => [] },
selectedAreaList: { type: Array, default: () => [] },
pubTimeList: { type: Array, default: () => [] },
selectedPubTimeList: { type: Array, default: () => [] },
curFooterList: { type: Array, default: () => [] },
total: { type: Number, default: 0 },
currentPage: { type: Number, default: 1 }
currentPage: { type: Number, default: 1 },
loading: { type: Boolean, default: false }
});
const emit = defineEmits([
......@@ -102,6 +111,13 @@ const formatDate = (str) => {
const [y, m, d] = str.split('T')[0].split('-')
return `${y}${+m}${+d}日`
};
const hasData = computed(() => Array.isArray(props.curFooterList) && props.curFooterList.length > 0);
const pageCount = computed(() => {
const size = 12;
const total = Number(props.total || 0) || 0;
return Math.max(1, Math.ceil(total / size));
});
</script>
<style lang="scss" scoped>
......@@ -195,14 +211,25 @@ const formatDate = (str) => {
.right {
width: 1284px;
max-height: 1377px;
display: flex;
flex-direction: column;
.card-box {
width: 1226px;
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 16px 16px;
.right-empty {
width: 100%;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.footer-card {
width: 398px;
height: 300px;
......
......@@ -36,8 +36,9 @@
</div>
</div>
<div class="right">
<div class="right" v-loading="loading">
<div class="card-box">
<template v-if="hasData">
<div class="footer-card" v-for="(item, index) in curFooterList" :key="index"
@click="emit('report-click', item)">
<div class="footer-card-top">
......@@ -51,11 +52,17 @@
<div class="from">{{ item.thinktankName }}</div>
</div>
</div>
</template>
<template v-else-if="!loading">
<div class="right-empty">
<el-empty class="right-el-empty" description="暂无数据" :image-size="100" />
</div>
</template>
</div>
<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="12" :page-count="pageCount" background layout="prev, pager, next"
@current-change="emit('page-change', $event)" :current-page="currentPage" />
</div>
</div>
......@@ -64,20 +71,22 @@
</template>
<script setup>
import { computed } from "vue";
import {
RESOURCE_FILTER_ALL_AREA,
RESOURCE_FILTER_ALL_TIME,
normalizeExclusiveAllOption
} from "../utils/resourceLibraryFilters";
defineProps({
const props = defineProps({
areaList: { type: Array, default: () => [] },
selectedAreaList: { type: Array, default: () => [] },
pubTimeList: { type: Array, default: () => [] },
selectedPubTimeList: { type: Array, default: () => [] },
curFooterList: { type: Array, default: () => [] },
total: { type: Number, default: 0 },
currentPage: { type: Number, default: 1 }
currentPage: { type: Number, default: 1 },
loading: { type: Boolean, default: false }
});
const formatDate = (str) => {
if (!str) return ''
......@@ -101,6 +110,13 @@ const handleTimeGroupChange = (val) => {
emit("update:selectedPubTimeList", normalizeExclusiveAllOption(val, RESOURCE_FILTER_ALL_TIME));
emit("filter-change");
};
const hasData = computed(() => Array.isArray(props.curFooterList) && props.curFooterList.length > 0);
const pageCount = computed(() => {
const size = 12;
const total = Number(props.total || 0) || 0;
return Math.max(1, Math.ceil(total / size));
});
</script>
<style lang="scss" scoped>
......@@ -170,14 +186,25 @@ const handleTimeGroupChange = (val) => {
.right {
width: 1284px;
max-height: 1377px;
display: flex;
flex-direction: column;
.card-box {
width: 1226px;
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 16px 16px;
.right-empty {
width: 100%;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.footer-card {
width: 398px;
......
......@@ -55,10 +55,10 @@
</div>
</div>
<div class="right">
<div class="right" v-loading="loading">
<div class="card-box">
<template v-if="hasData">
<div class="card-content">
<div v-for="(item, index) in hearingData" :key="item.id ?? index">
<div class="card-item">
<img class="card-item-img" :src="item.coverImgUrl" alt="report image" />
......@@ -72,7 +72,8 @@
class="card-open-image" />
</div>
<div class="card-item-category" v-if="item.domains">
<div class="card-item-category"
v-if="Array.isArray(item.domains) && item.domains.some(v => String(v || '').trim())">
<div v-for="(value, index) in item.domains" :key="`domain-${index}`">
<AreaTag :key="`cat-${item.id}`" :tagName="value" />
</div>
......@@ -81,16 +82,20 @@
</div>
<div class="divider" v-if="index !== hearingData.length - 1"></div>
</div>
</div>
</template>
<template v-else-if="!loading">
<div class="right-empty">
<el-empty class="right-el-empty" description="暂无数据" :image-size="100" />
</div>
</template>
</div>
<div class="right-footer">
<div class="info">
{{ hearingData.length }} 篇国会听证会
共 {{ total }} 篇国会听证会
</div>
<div class="page-box">
<el-pagination :page-size="pageSize" background layout="prev, pager, next" :total="total"
<el-pagination :page-size="pageSize" :page-count="pageCount" background layout="prev, pager, next"
@current-change="handlePageChange" :current-page="currentPage" />
</div>
</div>
......@@ -121,7 +126,8 @@ const props = defineProps({
selectedPubTimeList: { type: Array, default: () => [] },
hearingData: { type: Array, default: () => [] },
total: { type: Number, default: 0 },
currentPage: { type: Number, default: 1 }
currentPage: { type: Number, default: 1 },
loading: { type: Boolean, default: false }
});
const emit = defineEmits([
......@@ -133,6 +139,12 @@ const emit = defineEmits([
]);
const pageSize = 10;
const hasData = computed(() => Array.isArray(props.hearingData) && props.hearingData.length > 0);
const pageCount = computed(() => {
const size = Number(pageSize || 10) || 10;
const total = Number(props.total || 0) || 0;
return Math.max(1, Math.ceil(total / size));
});
const selectedResearchIds = computed(() => (
Array.isArray(props.selectedAreaList) && props.selectedAreaList.length
......@@ -274,11 +286,13 @@ const handlePageChange = page => {
.right {
width: 1224px;
display: flex;
flex-direction: column;
.card-box {
width: 100%;
flex: 1;
display: flex;
background: rgba(255, 255, 255, 1);
box-sizing: border-box;
......@@ -287,6 +301,15 @@ const handlePageChange = page => {
box-shadow: 0px 0px 20px 0px rgba(94, 95, 95, 0.1);
padding-right: 36px;
.right-empty {
width: 100%;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.card-content {
width: 1211px;
......
......@@ -36,8 +36,9 @@
</div>
</div>
<div class="right">
<div class="right" v-loading="loading">
<div class="card-box">
<template v-if="hasData">
<div class="card-content">
<div v-for="(item, index) in list" :key="item.id ?? index">
<div class="card-item" @click="emit('item-click', item)">
......@@ -79,11 +80,17 @@
<div class="divider" v-if="index !== list.length - 1"></div>
</div>
</div>
</template>
<template v-else-if="!loading">
<div class="right-empty">
<el-empty class="right-el-empty" description="暂无数据" :image-size="100" />
</div>
</template>
</div>
<div class="right-footer">
<div class="info">共{{ total }}篇政策建议</div>
<div class="page-box">
<el-pagination :page-size="pageSize" background layout="prev, pager, next" :total="total"
<el-pagination :page-size="pageSize" :page-count="pageCount" background layout="prev, pager, next"
@current-change="p => emit('page-change', p)" :current-page="currentPage" />
</div>
</div>
......@@ -92,7 +99,7 @@
</template>
<script setup>
import { ref } from "vue";
import { ref, computed } from "vue";
import { useRouter } from "vue-router";
import AreaTag from "@/components/base/AreaTag/index.vue";
import {
......@@ -103,13 +110,14 @@ import {
stripAllTimeForRequest
} from "../utils/resourceLibraryFilters";
defineProps({
const props = defineProps({
researchTypeList: { type: Array, default: () => [] },
researchTimeList: { type: Array, default: () => [] },
list: { type: Array, default: () => [] },
total: { type: Number, default: 0 },
currentPage: { type: Number, default: 1 },
pageSize: { type: Number, default: 7 },
loading: { type: Boolean, default: false },
});
const emit = defineEmits(["filter-change", "page-change", "item-click"]);
......@@ -158,6 +166,13 @@ const handleAdministrativeMoreClick = (ad) => {
const selectedTypeIds = ref([RESOURCE_FILTER_ALL_AREA]);
const selectedYearIds = ref([RESOURCE_FILTER_ALL_TIME]);
const hasData = computed(() => Array.isArray(props.list) && props.list.length > 0);
const pageCount = computed(() => {
const size = Number(props.pageSize || 7) || 7;
const total = Number(props.total || 0) || 0;
return Math.max(1, Math.ceil(total / size));
});
const emitFilterToParent = () => {
emit("filter-change", {
researchTypeIds: stripAllAreaForRequest(selectedTypeIds.value),
......@@ -298,6 +313,8 @@ const handleYearGroupChange = (val) => {
.right {
width: 1224px;
display: flex;
flex-direction: column;
.card-box {
......@@ -311,6 +328,15 @@ const handleYearGroupChange = (val) => {
box-shadow: 0px 0px 20px 0px rgba(94, 95, 95, 0.1);
padding-right: 36px;
.right-empty {
width: 100%;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.card-content {
width: 1211px;
height: 1067px;
......
......@@ -49,7 +49,7 @@
<img class="translate-icon" src="../ReportDetail/images/image-translate.png" alt=""
style="width: 16px; height: 16px; max-width: 16px; max-height: 16px; display: block; object-fit: contain;" />
</div>
<div class="translate-text">{{ "显示文" }}</div>
<div class="translate-text">{{ "显示文" }}</div>
</div>
<div class="btn" @click="handleDownload">
<div class="icon">
......@@ -61,13 +61,15 @@
</div>
</div>
<div class="report-box">
<div class="pdf-pane-wrap" v-if="valueSwitch && reportUrlEnWithPage">
<pdf ref="leftPdfRef" :pdfUrl="reportUrlEnWithPage" class="pdf-pane-inner" />
</div>
<div class="pdf-pane-wrap" :class="{ 'is-full': !valueSwitch }" v-if="reportUrlWithPage">
<pdf :key="`right-pdf-${valueSwitch ? 'split' : 'full'}`" ref="rightPdfRef" :pdfUrl="reportUrlWithPage"
<!-- 英文原文:始终展示;关闭「显示译文」时占满宽度 -->
<div class="pdf-pane-wrap" :class="{ 'is-full': !valueSwitch }" v-if="reportUrlEnWithPage">
<pdf :key="`left-pdf-${valueSwitch ? 'split' : 'full'}`" ref="leftPdfRef" :pdfUrl="reportUrlEnWithPage"
class="pdf-pane-inner" />
</div>
<!-- 中文译文:仅在开关打开时展示 -->
<div class="pdf-pane-wrap" v-if="valueSwitch && reportUrlWithPage">
<pdf ref="rightPdfRef" :pdfUrl="reportUrlWithPage" class="pdf-pane-inner" />
</div>
</div>
</div>
</div>
......
import * as echarts from 'echarts';
import { MUTICHARTCOLORS } from "@/common/constant.js";
/**
* @param {{ title: unknown[], data: Array<{ name: string, value: unknown[], color?: string }> }} data
......@@ -9,70 +10,21 @@ const getMultiLineChart = (data) => {
const series = data.data
const allNames = series.map((item) => item.name)
const legendSplitAt = Math.ceil(allNames.length / 2)
const legendFirstLine = allNames.slice(0, legendSplitAt)
const legendSecondLine = allNames.slice(legendSplitAt)
// 按 AreaTag 的颜色规则映射到折线图配色(取 tag 的文字色)
const AREA_TAG_COLOR_BY_NAME = {
'人工智能': 'rgba(245, 34, 45, 1)', // tag1
'生物科技': 'rgba(19, 168, 168, 1)', // tag2
'新一代通信网络': 'rgba(5, 95, 194, 1)', // tag3
// 兼容后端/页面常见写法
'通信网络': 'rgba(5, 95, 194, 1)',
'量子科技': 'rgba(114, 46, 209, 1)', // tag4
'新能源': 'rgba(82, 196, 26, 1)', // tag5
'集成电路': 'rgba(22, 119, 255, 1)', // tag6
'海洋': 'rgba(15, 120, 199, 1)', // tag7
'先进制造': 'rgba(250, 173, 20, 1)', // tag8
'新材料': 'rgba(250, 140, 22, 1)', // tag9
'航空航天': 'rgba(47, 84, 235, 1)', // tag10
'太空': 'rgba(47, 84, 235, 1)', // tag11
'深海': 'rgba(73, 104, 161, 1)', // tag12
'极地': 'rgba(133, 165, 255, 1)', // tag13
'核': 'rgba(250, 84, 28, 1)', // tag14
'其他': 'rgba(82, 196, 26, 1)' // tag15
}
// 兜底颜色池(未命中 AreaTag 映射时使用)
const fallbackColorList = [
'rgba(5, 95, 194, 1)',
'rgba(19, 168, 168, 1)',
'rgba(250, 140, 22, 1)',
'rgba(114, 46, 209, 1)',
'rgba(82, 196, 26, 1)',
'rgba(250, 84, 28, 1)',
'rgba(22, 119, 255, 1)',
'rgba(95, 101, 108, 1)',
'rgba(47, 84, 235, 1)',
'rgba(133, 165, 255, 1)',
]
// 解析 RGBA 颜色的辅助函数
const parseRgba = (colorStr) => {
// 匹配 rgba(r, g, b, a) 格式
const match = colorStr.match(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*(\d+(\.\d+)?)\)/);
if (match) {
const parseHexToRgb = (hex) => {
const h = String(hex || '').replace('#', '').trim()
if (h.length !== 6) return { r: 0, g: 0, b: 0 }
return {
r: parseInt(match[1]),
g: parseInt(match[2]),
b: parseInt(match[3]),
a: parseFloat(match[4])
};
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
}
}
// 默认返回黑色
return { r: 0, g: 0, b: 0, a: 1 };
};
// 动态生成 series 配置
const echartsSeries = series.map((item, index) => {
// 获取当前系列的颜色(优先使用item.color,否则用预设颜色,再否则随机)
const baseColor =
item.color ||
AREA_TAG_COLOR_BY_NAME[item.name] ||
fallbackColorList[index % fallbackColorList.length] ||
`rgba(${Math.floor(Math.random() * 256)}, ${Math.floor(Math.random() * 256)}, ${Math.floor(Math.random() * 256)}, 1)`;
const { r, g, b } = parseRgba(baseColor);
const baseColor = item.color || MUTICHARTCOLORS[index % MUTICHARTCOLORS.length] || '#055FC2'
const { r, g, b } = parseHexToRgb(baseColor)
return ({
name: item.name,
......@@ -89,11 +41,11 @@ const getMultiLineChart = (data) => {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0, // 顶部
color: `rgba(${r}, ${g}, ${b}, 0.1)` // 按需求:0.1 -> 0
color: `rgba(${r}, ${g}, ${b}, 0.1)`
},
{
offset: 1, // 底部
color: `rgba(${r}, ${g}, ${b}, 0)` // 0 透明度
color: `rgba(${r}, ${g}, ${b}, 0)`
}
])
},
......@@ -118,20 +70,24 @@ const getMultiLineChart = (data) => {
},
/* 顶部预留足够空间:多行图例 + Y 轴标题「数量」在纵轴顶端,避免与图例重合 */
grid: {
top: '34%',
top: 68,
right: '3%',
bottom: '5%',
left: '2%',
containLabel: true
},
legend: [
{
legend: {
show: true,
type: 'plain',
data: legendFirstLine,
top: 8,
orient: 'horizontal',
left: 'center',
top: 8,
width: '90%',
height: 24,
icon: 'circle',
itemWidth: 12,
itemHeight: 12,
data: allNames,
textStyle: {
fontFamily: 'Source Han Sans CN',
fontWeight: 400,
......@@ -141,23 +97,6 @@ const getMultiLineChart = (data) => {
align: 'left'
}
},
{
show: legendSecondLine.length > 0,
type: 'plain',
data: legendSecondLine,
top: 32,
left: 'center',
icon: 'circle',
textStyle: {
fontFamily: 'Source Han Sans CN',
fontWeight: 400,
fontSize: 14,
lineHeight: 24,
letterSpacing: 0,
align: 'left'
}
}
],
// 不使用全局 color,改为每条 series 自己定色(与 AreaTag 一致)
xAxis: [
{
......
import { MUTICHARTCOLORS } from "@/common/constant.js";
const getPieChart = (data) => {
const seriesData = (Array.isArray(data) ? data : []).map((d) => {
const color = d?.color
if (!color) return d
const seriesData = (Array.isArray(data) ? data : []).map((d, index) => {
const color = d?.color || MUTICHARTCOLORS[index % MUTICHARTCOLORS.length]
return {
...d,
itemStyle: { ...(d.itemStyle || {}), color },
itemStyle: { ...(d?.itemStyle || {}), color },
// “飞线”(labelLine)跟随领域色
labelLine: {
...(d.labelLine || {}),
lineStyle: { ...(d.labelLine?.lineStyle || {}), color }
...(d?.labelLine || {}),
lineStyle: { ...(d?.labelLine?.lineStyle || {}), color }
}
}
})
......@@ -38,7 +39,11 @@ const getPieChart = (data) => {
formatter: params => {
const name = params.name || "";
const value = Number(params.value ?? 0) || 0;
const percent = params.percent != null ? Number(params.percent).toFixed(2) : "0.00";
const rawPercent =
params?.data?.percent != null
? params.data.percent
: params.percent;
const percent = rawPercent != null ? Number(rawPercent).toFixed(2) : "0.00";
// 第二行:数值 + 百分比(同一行、同一文字样式),保持与旧版一致的两行结构
return `{name|${name}}\n{time|${value}${percent}%}`;
......
......@@ -124,7 +124,20 @@ export function matchesEarlierChineseDate(timeStr) {
/** 与政策追踪「仅全部时间」一致:固定起止(结束日按产品要求) */
export const RESOURCE_REPORT_ALL_TIME_START = "2000-01-01";
export const RESOURCE_REPORT_ALL_TIME_END = "2025-12-31";
function getYesterdayYmd() {
const d = new Date();
// JS Date 自动处理跨月/跨年
d.setDate(d.getDate() - 1);
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}`;
}
export function getResourceReportAllTimeEndYmd() {
return getYesterdayYmd();
}
function getResourceReportDateYearsAgo(years) {
const currentDate = new Date();
......@@ -145,7 +158,7 @@ function getResourceReportTodayYmd() {
/**
* 资源库 /thinkTankOverview/report:由发布时间多选(数字年 +「更早」)推导 startDate/endDate,语义对齐政策追踪 getPolicyListDateRangeFromYearList。
* - 仅「全部时间」或选满全部具体年份 → 2000-01-01 ~ 2025-12-31
* - 仅「全部时间」或选满全部具体年份 → 2000-01-01 ~ 昨天
* - 单选/多选自然年 +「更早」→ 取最小年 01-01 与最大年 12-31 包络(「更早」为 2000~2020)
* - 无法解析时的兜底:近 relativeYearsAgo 年至今天
*
......@@ -169,7 +182,7 @@ export function getResourceLibraryReportDateRangeFromTimeSelection(
if (isAllTime) {
return {
startDate: RESOURCE_REPORT_ALL_TIME_START,
endDate: RESOURCE_REPORT_ALL_TIME_END,
endDate: getResourceReportAllTimeEndYmd(),
};
}
......@@ -206,6 +219,6 @@ export function getResourceLibraryReportDateRangeFromTimeSelection(
}
return {
startDate: `${minY}-01-01`,
endDate: `${maxY}-12-31`,
endDate: maxY === new Date().getFullYear() ? getYesterdayYmd() : `${maxY}-12-31`,
};
}
import { MUTICHARTCOLORS } from "@/common/constant.js";
const getSankeyChart = (nodes, links) => {
const formatAmountWan = (v) => {
const n = Number(v)
......@@ -35,6 +37,11 @@ const getSankeyChart = (nodes, links) => {
right: '15%',
top: '5%',
bottom: '5%',
color: MUTICHARTCOLORS,
lineStyle: {
color: 'rgb(230, 231, 232)',
opacity: 1
},
emphasis: {
focus: 'adjacency'
},
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论