提交 4d67858d authored 作者: 张烨's avatar 张烨

Merge remote-tracking branch 'origin/pre' into zy-dev

......@@ -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
})
}
......@@ -180,7 +180,8 @@ export function getThinkTankTestimoniesByThinkTankId(params) {
params: {
pageNum: params.pageNum,
pageSize: params.pageSize,
sortField: params.sortField,
sortOrder: params.sortOrder,
domainIds: params.domainIds,
startDate: params.startDate,
endDate: params.endDate,
......
<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">
<rect id="降序 12" width="16.000000" height="16.000000" x="0.000000" y="0.000000" />
<path id="路径" d="M12.6473 7.70235L14.0825 6.30313C14.1606 6.22657 14.2121 6.12657 14.2278 6.01719C14.27 5.74376 14.0793 5.49063 13.8059 5.45001L9.83871 4.87344L8.06527 1.27813C8.01683 1.17969 7.93715 1.10001 7.83871 1.05157C7.59183 0.929693 7.29183 1.03126 7.1684 1.27813L5.39496 4.87344L1.42777 5.45001C1.3184 5.46563 1.2184 5.51719 1.14183 5.59532C0.949647 5.79376 0.952772 6.10938 1.15121 6.30313L4.02152 9.10157L3.3434 13.0531C3.32465 13.1609 3.34183 13.2734 3.3934 13.3703C3.52152 13.6141 3.82465 13.7094 4.0684 13.5797L7.61684 11.7141L8.50394 12.1805" stroke="rgb(95, 101, 108)" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.000000" />
<path id="矢量 1983" d="M0 0L4 0" stroke="rgb(95, 101, 108)" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.000000" transform="matrix(0,1,-1,0,12,9)" />
<path id="矢量 1984" d="M10 12L12 14L14 12" stroke="rgb(95, 101, 108)" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.000000" />
</svg>
<template>
<div class="grade-sort-select-box">
<el-select v-model="isSort" placeholder="评分排序" style="width: 130px" @change="handlePxChange">
<template #prefix>
<div style="display: flex; align-items: center; height: 100%">
<img v-if="isSort" src="./down.svg" style="width: 16px; height: 16px" />
<img v-else src="./up.svg" style="width: 16px; height: 16px" />
</div>
</template>
<el-option v-for="item in gradeList" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</div>
</template>
<script setup>
import { ref } from 'vue'
const isSort = ref(true)
const gradeList = ref([
{ label: "评分倒序", value: true },
{ label: "评分正序", value: false }
])
const emits = defineEmits(['handlePxChange'])
const handlePxChange = () => {
emits('handlePxChange', isSort.value)
}
</script>
<style scoped>
.grade-sort-select-box {
height: 42px;
box-sizing: border-box;
padding: 5px 0;
}
</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">
<rect id="降序 11" width="16.000000" height="16.000000" x="0.000000" y="0.000000" />
<path id="矢量 1983" d="M0 0L4 0" stroke="rgb(95, 101, 108)" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.000000" transform="matrix(0,1,-1,0,12,10)" />
<path id="矢量 1984" d="M10 10.9969L12.0003 9L14 10.9969" stroke="rgb(95, 101, 108)" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.000000" />
<path id="路径" d="M12.6473 7.70235L14.0825 6.30313C14.1606 6.22657 14.2121 6.12657 14.2278 6.01719C14.27 5.74376 14.0793 5.49063 13.8059 5.45001L9.83871 4.87344L8.06527 1.27813C8.01683 1.17969 7.93715 1.10001 7.83871 1.05157C7.59183 0.929693 7.29183 1.03126 7.1684 1.27813L5.39496 4.87344L1.42777 5.45001C1.3184 5.46563 1.2184 5.51719 1.14183 5.59532C0.949647 5.79376 0.952772 6.10938 1.15121 6.30313L4.02152 9.10157L3.3434 13.0531C3.32465 13.1609 3.34183 13.2734 3.3934 13.3703C3.52152 13.6141 3.82465 13.7094 4.0684 13.5797L7.61684 11.7141L9.39105 12.6469" stroke="rgb(95, 101, 108)" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.000000" />
</svg>
<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">
<rect id="降序 8" width="16.000000" height="16.000000" x="0.000000" y="0.000000" />
<rect id="矩形 6349" width="12.000000" height="1.000000" x="2.000000" y="3.000000" rx="0.500000" fill="rgb(95, 101, 108)" />
<rect id="矩形 6350" width="12.000000" height="1.000000" x="2.000000" y="6.000000" rx="0.500000" fill="rgb(95, 101, 108)" />
<rect id="矩形 6351" width="6.000000" height="1.000000" x="2.000000" y="9.000000" rx="0.500000" fill="rgb(95, 101, 108)" />
<rect id="矩形 6352" width="6.000000" height="1.000000" x="2.000000" y="12.000000" rx="0.500000" fill="rgb(95, 101, 108)" />
<path id="矢量 1983" d="M0 0L4 0" stroke="rgb(95, 101, 108)" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.000000" transform="matrix(0,1,-1,0,12,9)" />
<path id="矢量 1984" d="M10 12L12 14L14 12" stroke="rgb(95, 101, 108)" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.000000" />
</svg>
<template>
<div class="heat-sort-select-box">
<el-select v-model="isSort" placeholder="热度排序" style="width: 130px" @change="handlePxChange">
<template #prefix>
<div style="display: flex; align-items: center; height: 100%">
<img v-if="isSort" src="./down.svg" style="width: 16px; height: 16px" />
<img v-else src="./up.svg" style="width: 16px; height: 16px" />
</div>
</template>
<el-option v-for="item in heatList" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</div>
</template>
<script setup>
import { ref } from 'vue'
const isSort = ref(true)
const heatList = ref([
{ label: "热度倒序", value: true },
{ label: "热度正序", value: false }
])
const emits = defineEmits(['handlePxChange'])
const handlePxChange = () => {
emits('handlePxChange', isSort.value)
}
</script>
<style scoped>
.heat-sort-select-box {
height: 42px;
box-sizing: border-box;
padding: 5px 0;
}
</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">
<rect id="降序 10" width="16.000000" height="16.000000" x="0.000000" y="0.000000" />
<path id="矢量 1983" d="M0 0L4 0" stroke="rgb(95, 101, 108)" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.000000" transform="matrix(0,1,-1,0,12,10)" />
<path id="矢量 1984" d="M10 10.9969L12.0003 9L14 10.9969" stroke="rgb(95, 101, 108)" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.000000" />
<rect id="矩形 6349" width="12.000000" height="1.000000" x="2.000000" y="3.000000" rx="0.500000" fill="rgb(95, 101, 108)" />
<rect id="矩形 6350" width="12.000000" height="1.000000" x="2.000000" y="6.000000" rx="0.500000" fill="rgb(95, 101, 108)" />
<rect id="矩形 6351" width="6.000000" height="1.000000" x="2.000000" y="9.000000" rx="0.500000" fill="rgb(95, 101, 108)" />
<rect id="矩形 6352" width="6.000000" height="1.000000" x="2.000000" y="12.000000" rx="0.500000" fill="rgb(95, 101, 108)" />
</svg>
<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">
<rect id="降序 8" width="16.000000" height="16.000000" x="0.000000" y="0.000000" />
<rect id="矩形 6349" width="12.000000" height="1.000000" x="2.000000" y="3.000000" rx="0.500000" fill="rgb(95, 101, 108)" />
<rect id="矩形 6350" width="12.000000" height="1.000000" x="2.000000" y="6.000000" rx="0.500000" fill="rgb(95, 101, 108)" />
<rect id="矩形 6351" width="6.000000" height="1.000000" x="2.000000" y="9.000000" rx="0.500000" fill="rgb(95, 101, 108)" />
<rect id="矩形 6352" width="6.000000" height="1.000000" x="2.000000" y="12.000000" rx="0.500000" fill="rgb(95, 101, 108)" />
<path id="矢量 1983" d="M0 0L4 0" stroke="rgb(95, 101, 108)" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.000000" transform="matrix(0,1,-1,0,12,9)" />
<path id="矢量 1984" d="M10 12L12 14L14 12" stroke="rgb(95, 101, 108)" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.000000" />
</svg>
<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">
<rect id="降序 12" width="16.000000" height="16.000000" x="0.000000" y="0.000000" />
<path id="路径" d="M12.6473 7.70235L14.0825 6.30313C14.1606 6.22657 14.2121 6.12657 14.2278 6.01719C14.27 5.74376 14.0793 5.49063 13.8059 5.45001L9.83871 4.87344L8.06527 1.27813C8.01683 1.17969 7.93715 1.10001 7.83871 1.05157C7.59183 0.929693 7.29183 1.03126 7.1684 1.27813L5.39496 4.87344L1.42777 5.45001C1.3184 5.46563 1.2184 5.51719 1.14183 5.59532C0.949647 5.79376 0.952772 6.10938 1.15121 6.30313L4.02152 9.10157L3.3434 13.0531C3.32465 13.1609 3.34183 13.2734 3.3934 13.3703C3.52152 13.6141 3.82465 13.7094 4.0684 13.5797L7.61684 11.7141L8.50394 12.1805" stroke="rgb(95, 101, 108)" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.000000" />
<path id="矢量 1983" d="M0 0L4 0" stroke="rgb(95, 101, 108)" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.000000" transform="matrix(0,1,-1,0,12,9)" />
<path id="矢量 1984" d="M10 12L12 14L14 12" stroke="rgb(95, 101, 108)" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.000000" />
</svg>
<template>
<div class="time-sort-select-box">
<el-select v-model="isSort" placeholder="发布时间" style="width: 130px" @change="handlePxChange">
<el-select v-model="sortValue" placeholder="排序方式" style="width: 130px" @change="handlePxChange">
<template #prefix>
<div style="display: flex; align-items: center; height: 100%">
<img v-if="isSort" src="./down.svg" style="width: 16px; height: 16px" />
<img v-else src="./up.svg" style="width: 16px; height: 16px" />
<img v-if="sortValue === 1" src="./down.svg" style="width: 16px; height: 16px" />
<img v-else-if="sortValue === 2" src="./up.svg" style="width: 16px; height: 16px" />
<img v-else-if="sortValue === 3" src="./down1.svg" style="width: 16px; height: 16px" />
<img v-else-if="sortValue === 4" src="./down2.svg" style="width: 16px; height: 16px" />
</div>
</template>
<el-option v-for="item in timeList" :key="item.value" :label="item.label" :value="item.value" />
<el-option v-for="item in sortList" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { computed, ref } from 'vue'
const isSort = ref(true)
const props = defineProps({
sortDemension: {
type: Number,
default: 1
}
})
const timeList = ref([
{ label: "时间倒序", value: true },
{ label: "时间正序", value: false }
])
const sortValue = ref(1)
const sortList = computed(() => {
if (props.sortDemension === 1) {
return [
{ label: "时间倒序", value: 1 },
{ label: "时间正序", value: 2 },
]
} else if (props.sortDemension === 2) {
return [
{ label: "时间倒序", value: 1 },
{ label: "时间正序", value: 2 },
{ label: "评分倒序", value: 3 },
]
} else {
return [
{ label: "时间倒序", value: 1 },
{ label: "时间正序", value: 2 },
{ label: "评分倒序", value: 3 },
{ label: "热度倒序", value: 4 },
]
}
})
const emits = defineEmits(['handlePxChange'])
const handlePxChange = () => {
emits('handlePxChange', isSort.value)
emits('handlePxChange', sortValue.value)
}
</script>
......
......@@ -15,7 +15,7 @@ const thinktankRoutes = [
name: "thinkTank",
component: thinkTank,
meta: {
title: "科技智库概览",
title: "美国科技智库概览",
isShowHeader: true
}
},
......
......@@ -4,15 +4,19 @@
<pre>
{{
`
import TimeSortSelectBox from '@/components/base/TimeSortSelectBox/index.vue'
<div class="time-box">
<TimeSortSelectBox @handle-px-change="handlePx" />
</div>
import TimeSortSelectBox from '@/components/base/TimeSortSelectBox/index.vue'
<div class="time-box">
<TimeSortSelectBox @handle-px-change="handleTimePx" />
<TimeSortSelectBox :sort-demension="2" @handle-px-change="handleTimePx" />
<TimeSortSelectBox :sort-demension="3" @handle-px-change="handleTimePx" />
</div>
`
}}
</pre>
<div class="time-box">
<TimeSortSelectBox @handle-px-change="handlePx" />
<TimeSortSelectBox @handle-px-change="handleTimePx" />
<TimeSortSelectBox :sort-demension="2" @handle-px-change="handleTimePx" />
<TimeSortSelectBox :sort-demension="3" @handle-px-change="handleTimePx" />
</div>
</el-col>
......@@ -24,12 +28,24 @@
import { ref } from 'vue'
import '@/styles/common.scss'
import TimeSortSelectBox from '@/components/base/TimeSortSelectBox/index.vue'
import HeatSortSelectBox from '@/components/base/HeatSortSelectBox/index.vue'
import GradeSortSelectBox from '@/components/base/GradeSortSelectBox/index.vue'
const span = 12
const handlePx = (val) => {
const handleTimePx = (val) => {
alert(val)
}
const handleHeatPx = (val) => {
alert(val)
}
const handleGradePx = (val) => {
alert(val)
}
</script>
<style lang="scss" scoped>
......@@ -39,5 +55,7 @@ const handlePx = (val) => {
padding: 100px;
background: #F2F8FF;
border: 1px solid var(--bg-black-5);
display: flex;
gap: 8px;
}
</style>
\ No newline at end of file
......@@ -8,8 +8,8 @@
<div class="text-title-1-show">文字样式</div>
<TextStyle />
<div class="text-title-1-show">通用样式/组件</div>
<div style="position: relative; min-height: 700px;">
<el-tabs tabPosition="left" class="tabs-nav-no-wrap left-float-nav-tabs dev-style-tabs">
<div style="position: relative; height: 800px;">
<el-tabs tabPosition="left" style="position: relative; height: 700px;" class="tabs-nav-no-wrap left-float-nav-tabs dev-style-tabs">
<el-tab-pane label="通用" lazy>
<common-page />
</el-tab-pane>
......@@ -73,10 +73,9 @@
<el-tab-pane label="激活工作框" lazy>
<WorkingBox />
</el-tab-pane>
<el-tab-pane label="时间排序" lazy>
<el-tab-pane label="自定义排序" lazy>
<TimeSortSelectBox />
</el-tab-pane>
</el-tabs>
</div>
</el-space>
......
......@@ -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) {
console.error("合作限制政策对比图表解读请求失败", error);
aiContentLeft.value = "解读加载失败";
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) {
console.error("合作限制领域分布图表解读请求失败", error);
aiContentRight.value = "解读加载失败";
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 = {
......@@ -658,13 +663,16 @@ watch(currentPage, () => {
.title {
font-size: 20px;
width: 950px;
max-width: 950px;
font-weight: 700;
font-family: "Microsoft YaHei";
line-height: 26px;
color: rgb(59, 65, 75);
margin-bottom: 9px;
cursor: pointer;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.content {
......
......@@ -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;
console.error("图表解读请求失败", e);
targetRef.value = "解读加载失败";
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) {
console.error("报告关键词云图表解读请求失败", error);
aiContentBox5.value = "解读加载失败";
if (error?.name !== "AbortError") {
console.error("报告关键词云图表解读请求失败", error);
aiContentBox5.value = "解读加载失败";
}
} finally {
isBox5InterpretLoading.value = false;
box5AiAbortController.value = null;
}
};
......
......@@ -248,7 +248,7 @@ const handleDownloadDocument = async () => {
display: flex;
img {
width: 72px;
width: 178px;
height: 88px;
}
......
......@@ -68,11 +68,11 @@
<div class="info-content">
<div class="info-item">
<div class="info-text">{{ "相关领域:" }}</div>
<div class="info-right" v-if="box1DataItem && box1DataItem.domains">
<div class="info-right" v-if="hasDomainTags">
<div class="tag-box">
<div class="tag" v-for="(item, index) in box1DataItem.domains" :key="index" v-show="item">{{
item.industryName
}}</div>
<div class="tag" v-for="(name, index) in domainTags" :key="name + '-' + index">
{{ name }}
</div>
</div>
</div>
</div>
......@@ -155,6 +155,12 @@ const activeItemIndex = ref(0);
const pageSize = ref(10);
// 当前选中 item 的数据
const box1DataItem = ref(null);
const domainTags = computed(() => {
const domains = box1DataItem.value?.domains;
const list = Array.isArray(domains) ? domains : [];
return list.map((d) => d?.industryName).filter(Boolean);
});
const hasDomainTags = computed(() => domainTags.value.length > 0);
// 整个页面容器,用于分页后滚回 wrap 顶部
const wrapRef = ref(null);
const reportUrl = ref("");
......
......@@ -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) {
console.error("报告关键词云图表解读请求失败", error);
aiContentBox5.value = "解读加载失败";
if (error?.name !== "AbortError") {
console.error("报告关键词云图表解读请求失败", error);
aiContentBox5.value = "解读加载失败";
}
} finally {
isBox5InterpretLoading.value = false;
box5AiAbortController.value = null;
}
};
......@@ -963,7 +975,7 @@ onMounted(() => {
display: flex;
.left {
width: 56px;
width: 150px;
height: 74px;
margin-top: 8px;
......
......@@ -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) {
console.error("报告关键词云图表解读请求失败", error);
aiContentBox5.value = "解读加载失败";
if (error?.name !== "AbortError") {
console.error("报告关键词云图表解读请求失败", error);
aiContentBox5.value = "解读加载失败";
}
} finally {
isBox5InterpretLoading.value = false;
box5AiAbortController.value = null;
}
};
......
<template>
<div class="wrap">
<div class="wrap" ref="pageScrollRef">
<div class="top">
<div class="box1 box">
<div class="box-header">
......@@ -228,52 +228,58 @@
</div>
<div class="right" v-loading="loading">
<div class="right-main">
<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" v-html="highlightPolicyText(item.titleZh)"></div>
<div class="info">
<div class="text">
<span v-html="highlightPolicyText(`${item.reportDateDisplay}·${item.reportName}`)"></span>
<template v-if="policyList && policyList.length">
<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" v-html="highlightPolicyText(item.titleZh)"></div>
<div class="info">
<div class="text">
<span v-html="highlightPolicyText(`${item.reportDateDisplay}·${item.reportName}`)"></span>
</div>
<div class="more" @click="toDetail(item)">
<img src="@/views/thinkTank/ThinkTankDetail/thinkDynamics/images/image open.png" alt="" />
</div>
</div>
<div class="more" @click="toDetail(item)">
<img src="@/views/thinkTank/ThinkTankDetail/thinkDynamics/images/image open.png" alt="" />
<div class="tag-box">
<AreaTag v-for="(tag, idx) in item.tagList" :key="idx" :tagName="tag"></AreaTag>
</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.relatedBills" :key="`${file.id}-${idxx}`">
<div class="type">法案</div>
<div class="title">{{ file.name }}</div>
<div class="more" @click.stop="handleBillMoreClick(file)">
<img src="./images/arrow-right.png" alt="" />
<div class="file-box">
<div class="file" v-for="(file, idxx) in item.relatedBills" :key="`${file.id}-${idxx}`">
<div class="type">法案</div>
<div class="title">{{ file.name }}</div>
<div class="more" @click.stop="handleBillMoreClick(file)">
<img src="./images/arrow-right.png" alt="" />
</div>
</div>
</div>
<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.content }}</div>
<div class="more" @click.stop="handleAdministrativeMoreClick(file)">
<img src="./images/arrow-right.png" alt="" />
<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.content }}</div>
<div class="more" @click.stop="handleAdministrativeMoreClick(file)">
<img src="./images/arrow-right.png" alt="" />
</div>
</div>
</div>
</div>
<div></div>
</div>
<div></div>
</div>
</div>
</template>
<template v-else-if="!loading">
<div class="right-empty">
<el-empty class="policy-tracking-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" :total="total"
@current-change="handleCurrentChange" :current-page="currentPage" />
</div>
</div>
......@@ -309,6 +315,7 @@ import { useRouter } from "vue-router";
const router = useRouter();
const loading = ref(false);
const pageScrollRef = ref(null);
/** 与 AreaTag 一致的领域色(取 tag 的文字色) */
const AREA_TAG_COLOR_BY_NAME = {
......@@ -357,34 +364,55 @@ const POLICY_FILTER_ALL_AREA = "全部领域";
const POLICY_FILTER_ALL_TIME = "全部时间";
const POLICY_FILTER_ALL_DEPT = "全部部门";
// 刷新后默认展示 3 个图表 AI 总结
const isShowAiContentPolicyPt1 = ref(true);
// 默认仅展示 AiButton,悬停后再请求 AI
const isShowAiContentPolicyPt1 = ref(false);
const aiContentPolicyPt1 = ref("");
const isPolicyPt1InterpretLoading = ref(false);
const pt1AiAbortController = ref(null);
const handleSwitchAiContentShowPolicyPt1 = (val) => {
isShowAiContentPolicyPt1.value = val;
if (val) {
fetchPolicyPtBox1ChartInterpretation();
} else {
if (pt1AiAbortController.value) {
pt1AiAbortController.value.abort();
pt1AiAbortController.value = null;
}
isPolicyPt1InterpretLoading.value = false;
}
};
const isShowAiContentPolicyPt2 = ref(true);
const isShowAiContentPolicyPt2 = ref(false);
const aiContentPolicyPt2 = ref("");
const isPolicyPt2InterpretLoading = ref(false);
const pt2AiAbortController = ref(null);
const handleSwitchAiContentShowPolicyPt2 = (val) => {
isShowAiContentPolicyPt2.value = val;
if (val) {
fetchPolicyPtBox2ChartInterpretation();
} else {
if (pt2AiAbortController.value) {
pt2AiAbortController.value.abort();
pt2AiAbortController.value = null;
}
isPolicyPt2InterpretLoading.value = false;
}
};
const isShowAiContentPolicyPt3 = ref(true);
const isShowAiContentPolicyPt3 = ref(false);
const aiContentPolicyPt3 = ref("");
const isPolicyPt3InterpretLoading = ref(false);
const pt3AiAbortController = ref(null);
const handleSwitchAiContentShowPolicyPt3 = (val) => {
isShowAiContentPolicyPt3.value = val;
if (val) {
fetchPolicyPtBox3ChartInterpretation();
} else {
if (pt3AiAbortController.value) {
pt3AiAbortController.value.abort();
pt3AiAbortController.value = null;
}
isPolicyPt3InterpretLoading.value = false;
}
};
// import Img1 from "./images/img1.png";
......@@ -460,7 +488,7 @@ const box1YearList = ref([
value: "2022"
},
]);
const selectableYears = ref(["2025年", "2024年", "2023年", "2022年", "2021年", "更早"]);
const selectableYears = ref(["2026年", "2025年", "2024年", "2023年", "2022年", "2021年", "更早"]);
const selectableDepartment = ref([]);
const handleGetThinkPolicyIndustry = async () => {
aiContentPolicyPt1.value = "";
......@@ -472,25 +500,48 @@ const handleGetThinkPolicyIndustry = async () => {
const res = await getThinkPolicyIndustry(parmas);
console.log("提出建议领域分布", res);
if (res.code === 200 && res.data) {
const list = Array.isArray(res.data) ? res.data.slice(0, 7) : [];
if (!list.length) {
const raw = Array.isArray(res.data) ? res.data : [];
if (!raw.length) {
box1Data.value = [];
return;
}
const data = list.map((item, idx) => ({
// 前端统一计算:总项数 + 百分比(保留两位小数),展示前 7,若超过 7 则追加“其他”
const listSorted = raw
.map((item) => ({
industry: String(item?.industry || "").trim(),
amount: Number(item?.amount ?? 0) || 0
}))
.filter((item) => item.industry)
.sort((a, b) => b.amount - a.amount);
const total = listSorted.reduce((s, it) => s + (Number(it.amount) || 0), 0);
const top = listSorted.slice(0, 7);
const rest = listSorted.slice(7);
const topSum = top.reduce((s, it) => s + (Number(it.amount) || 0), 0);
const data = top.map((item, idx) => ({
name: item.industry,
value: item.amount,
percent: item.percent,
percent: total > 0 ? ((item.amount / total) * 100).toFixed(2) : "0.00",
color: getAreaTagColor(item.industry, idx)
}));
// 超过 7 个领域时:追加“其他”
if (rest.length > 0 && top.length === 7) {
const otherValue = Math.max(0, total - topSum);
if (otherValue > 0) {
data.push({
name: "其他",
value: otherValue,
percent: total > 0 ? ((otherValue / total) * 100).toFixed(2) : "0.00",
color: getAreaTagColor("其他", data.length)
});
}
}
box1Data.value = data;
/* v-if 有数据后才挂载 #box1Chart,须等 DOM 更新后再 init echarts */
await nextTick();
const box1Chart = getPieChart(box1Data.value);
setChart(box1Chart, "box1Chart");
if (isShowAiContentPolicyPt1.value) {
fetchPolicyPtBox1ChartInterpretation();
}
// 仅在用户打开 AI 面板时才请求解读
} else {
box1Data.value = [];
}
......@@ -560,9 +611,7 @@ const handleGetPolicyAdviceDeptDistribution = async () => {
await nextTick();
const box2Chart = getPieChart(box2Data.value);
setChart(box2Chart, "box2Chart");
if (isShowAiContentPolicyPt2.value) {
fetchPolicyPtBox2ChartInterpretation();
}
// 仅在用户打开 AI 面板时才请求解读
} else {
box2Data.value = [];
}
......@@ -726,9 +775,7 @@ const handleGetThinkPolicyIndustryChange = async () => {
}
box3Data.value = frontendData;
await renderBox3Chart();
if (isShowAiContentPolicyPt3.value) {
fetchPolicyPtBox3ChartInterpretation();
}
// 仅在用户打开 AI 面板时才请求解读
} else {
box3Data.value = { title: [], data: [] };
}
......@@ -776,6 +823,10 @@ const fetchPolicyPtBox1ChartInterpretation = async () => {
if (hasValidContent || isPolicyPt1InterpretLoading.value) {
return;
}
if (pt1AiAbortController.value) {
pt1AiAbortController.value.abort();
}
pt1AiAbortController.value = new AbortController();
isPolicyPt1InterpretLoading.value = true;
aiContentPolicyPt1.value = "解读生成中…";
const chartPayload = {
......@@ -791,6 +842,7 @@ const fetchPolicyPtBox1ChartInterpretation = async () => {
const res = await getChartAnalysis(
{ text: JSON.stringify(chartPayload) },
{
signal: pt1AiAbortController.value.signal,
onChunk: chunk => {
appendAiInterpretationChunk(aiContentPolicyPt1, chunk);
}
......@@ -799,10 +851,13 @@ const fetchPolicyPtBox1ChartInterpretation = async () => {
const text = getInterpretationTextFromChartResponse(res);
aiContentPolicyPt1.value = text || aiContentPolicyPt1.value || "未返回有效解读内容";
} catch (error) {
console.error("政策追踪领域分布图表解读请求失败", error);
aiContentPolicyPt1.value = "解读加载失败";
if (error?.name !== "AbortError") {
console.error("政策追踪领域分布图表解读请求失败", error);
aiContentPolicyPt1.value = "解读加载失败";
}
} finally {
isPolicyPt1InterpretLoading.value = false;
pt1AiAbortController.value = null;
}
};
......@@ -821,6 +876,10 @@ const fetchPolicyPtBox2ChartInterpretation = async () => {
if (hasValidContent || isPolicyPt2InterpretLoading.value) {
return;
}
if (pt2AiAbortController.value) {
pt2AiAbortController.value.abort();
}
pt2AiAbortController.value = new AbortController();
isPolicyPt2InterpretLoading.value = true;
aiContentPolicyPt2.value = "解读生成中…";
const chartPayload = {
......@@ -836,6 +895,7 @@ const fetchPolicyPtBox2ChartInterpretation = async () => {
const res = await getChartAnalysis(
{ text: JSON.stringify(chartPayload) },
{
signal: pt2AiAbortController.value.signal,
onChunk: chunk => {
appendAiInterpretationChunk(aiContentPolicyPt2, chunk);
}
......@@ -844,10 +904,13 @@ const fetchPolicyPtBox2ChartInterpretation = async () => {
const text = getInterpretationTextFromChartResponse(res);
aiContentPolicyPt2.value = text || aiContentPolicyPt2.value || "未返回有效解读内容";
} catch (error) {
console.error("政策追踪部门分布图表解读请求失败", error);
aiContentPolicyPt2.value = "解读加载失败";
if (error?.name !== "AbortError") {
console.error("政策追踪部门分布图表解读请求失败", error);
aiContentPolicyPt2.value = "解读加载失败";
}
} finally {
isPolicyPt2InterpretLoading.value = false;
pt2AiAbortController.value = null;
}
};
......@@ -872,6 +935,10 @@ const fetchPolicyPtBox3ChartInterpretation = async () => {
if (hasValidContent || isPolicyPt3InterpretLoading.value) {
return;
}
if (pt3AiAbortController.value) {
pt3AiAbortController.value.abort();
}
pt3AiAbortController.value = new AbortController();
isPolicyPt3InterpretLoading.value = true;
aiContentPolicyPt3.value = "解读生成中…";
const chartPayload = {
......@@ -889,6 +956,7 @@ const fetchPolicyPtBox3ChartInterpretation = async () => {
const res = await getChartAnalysis(
{ text: JSON.stringify(chartPayload) },
{
signal: pt3AiAbortController.value.signal,
onChunk: chunk => {
appendAiInterpretationChunk(aiContentPolicyPt3, chunk);
}
......@@ -897,10 +965,13 @@ const fetchPolicyPtBox3ChartInterpretation = async () => {
const text = getInterpretationTextFromChartResponse(res);
aiContentPolicyPt3.value = text || aiContentPolicyPt3.value || "未返回有效解读内容";
} catch (error) {
console.error("政策追踪研究领域趋势图表解读请求失败", error);
aiContentPolicyPt3.value = "解读加载失败";
if (error?.name !== "AbortError") {
console.error("政策追踪研究领域趋势图表解读请求失败", error);
aiContentPolicyPt3.value = "解读加载失败";
}
} finally {
isPolicyPt3InterpretLoading.value = false;
pt3AiAbortController.value = null;
}
};
......@@ -1124,16 +1195,23 @@ 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";
const getPolicyAllTimeEndYmd = () => {
const d = new 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}`;
};
/**
* 根据发布时间多选(如「2025年」「更早」)推导列表查询 startDate / endDate
* - 仅选「2025年」→ 2025-01-01 ~ 2025-12-31
* - 仅选当前年(如「2026年」)→ 2026-01-01 ~ 昨天
* - 多选多个自然年 → 取最小年 01-01 与最大年 12-31 的包络
* - 「更早」→ 2000-01-01 ~ 2020-12-31,可与具体年份合并为并集区间
* - 仅「全部时间」→ 2000-01-01 ~ 2025-12-31(写死)
* - 仅「全部时间」→ 2000-01-01 ~ 昨天
* - 未选任何项(无「全部时间」)→ 与顶部「近一年/两年/三年」一致
*/
function getPolicyListDateRangeFromYearList(labels, relativeYearYears) {
......@@ -1143,7 +1221,7 @@ function getPolicyListDateRangeFromYearList(labels, relativeYearYears) {
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 };
return { startDate: POLICY_ALL_TIME_START_DATE, endDate: getPolicyAllTimeEndYmd() };
}
const years = Number(relativeYearYears) > 0 ? Number(relativeYearYears) : 1;
return { startDate: getDateYearsAgo(years), endDate: getTodayYmd() };
......@@ -1169,7 +1247,7 @@ function getPolicyListDateRangeFromYearList(labels, relativeYearYears) {
}
return {
startDate: `${minY}-01-01`,
endDate: `${maxY}-12-31`
endDate: maxY === new Date().getFullYear() ? getPolicyAllTimeEndYmd() : `${maxY}-12-31`
};
}
......@@ -1200,10 +1278,11 @@ function normalizePolicyDomains(domains) {
.filter(Boolean);
}
/** 列表行映射:左侧图为 organizations[0].logoUrl,文案与法案/政令用接口字段名 */
/** 列表行映射:左侧图优先 coverImageUrl(兼容 coverImage),回退 organizations[0].logoUrl */
function mapPolicyRowToView(row) {
const orgs = Array.isArray(row.organizations) ? row.organizations : [];
const logoUrl = orgs[0]?.logoUrl || defaultNewsIcon;
const cover = row?.coverImageUrl || row?.coverImage || "";
const logoUrl = cover || orgs[0]?.logoUrl || defaultNewsIcon;
const reportDateDisplay = formatPolicyReportDateToCn(row.reportDate);
return {
...row,
......@@ -1241,9 +1320,35 @@ const handleSwithSort = () => {
};
const currentPage = ref(1);
const pageCount = computed(() => {
const size = 10;
const t = Number(total.value || 0) || 0;
return Math.max(1, Math.ceil(t / size));
});
const getScrollableParent = (el) => {
let cur = el;
while (cur && cur !== document.body && cur !== document.documentElement) {
const style = window.getComputedStyle(cur);
const overflowY = style?.overflowY;
const isScrollable = overflowY === "auto" || overflowY === "scroll";
if (isScrollable && cur.scrollHeight > cur.clientHeight + 1) {
return cur;
}
cur = cur.parentElement;
}
return null;
};
const scrollToTop = async () => {
await nextTick();
const anchor = pageScrollRef.value;
if (!anchor) return;
const scrollEl = getScrollableParent(anchor) || anchor;
scrollEl.scrollTop = 0;
};
// 处理页码改变事件
const handleCurrentChange = page => {
currentPage.value = page;
scrollToTop();
handleGetThinkPolicy();
};
......@@ -1323,6 +1428,7 @@ onMounted(async () => {
.wrap {
width: 100%;
height: 100%;
overflow-y: auto;
.policy-tracking-el-empty {
padding: 0;
......@@ -2051,6 +2157,8 @@ onMounted(async () => {
border-radius: 10px;
box-shadow: 0px 0px 20px 0px rgba(25, 69, 130, 0.1);
background: rgba(255, 255, 255, 1);
display: flex;
flex-direction: column;
.right-main {
margin-top: 17px;
......@@ -2060,6 +2168,15 @@ onMounted(async () => {
padding-left: 37px;
padding-right: 0;
max-height: 1540px;
flex: 1;
.right-empty {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.right-main-item {
// height: 154px;
......@@ -2075,7 +2192,7 @@ onMounted(async () => {
display: flex;
.item-left {
width: 57px;
width: 156px;
height: 77px;
img {
......
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) {
return {
r: parseInt(match[1], 10),
g: parseInt(match[2], 10),
b: parseInt(match[3], 10),
a: parseFloat(match[4])
}
const parseHexToRgb = (hex) => {
const h = String(hex || '').replace('#', '').trim()
if (h.length !== 6) return { r: 0, g: 0, b: 0 }
return {
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,69 +58,65 @@ const getMultiLineChart = (chartInput) => {
},
/* 贴满 #box3Chart:四边 0,由 containLabel 在网格内为轴文字留位,避免左侧/底部大块留白 */
grid: {
top: 92,
top: 60,
right: 10,
bottom: 0,
left: 20,
containLabel: true
},
legend: [
{
show: true,
type: 'plain',
data: legendLine1,
top: 4,
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)'
},
itemWidth: 12,
itemHeight: 12
},
legend: {
show: true,
type: 'scroll',
orient: 'horizontal',
left: 8,
top: 6,
width: '95%',
height: 24,
padding: [0, 24, 0, 24],
icon: 'circle',
itemWidth: 12,
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,
fontSize: 14,
lineHeight: 24,
letterSpacing: 0,
align: 'left',
color: 'rgb(95, 101, 108)'
}
},
graphic: [
{
show: legendLine2.length > 0,
type: 'plain',
data: legendLine2,
top: 30,
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)'
},
itemWidth: 12,
itemHeight: 12
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
},
{
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)'
},
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 }
}
}
})
......@@ -29,10 +30,14 @@ const getPieChart = (data) => {
alignTo: 'edge',
formatter: params => {
const name = params.name || "";
const value = params.value ?? "";
const percent = params.percent != null ? Math.round(params.percent) : 0;
const value = Number(params.value ?? 0) || 0;
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| ${percent}%}`;
return `{name|${name}}\n{time|${value} ${percent}%}`;
},
minMargin: 10,
edgeDistance: 20,
......
......@@ -72,38 +72,46 @@
</div> -->
</div>
</div>
<div class="right">
<div class="right" v-loading="loading">
<div class="card-box">
<div class="card-content">
<div v-for="(item, index) in hearingData" :key="item.id">
<div class="card-item">
<img class="card-item-img" :src="item.coverImgUrl" alt="report image" />
<div class="card-item-text">
<div class="card-item-title" @click="emit('report-click', item)">
<span v-html="highlightText(item.title)"></span>
<template v-if="hasData">
<div class="card-content">
<div v-for="(item, index) in hearingData" :key="item.id">
<div class="card-item">
<img class="card-item-img" :src="item.coverImgUrl" alt="report image" />
<div class="card-item-text">
<div class="card-item-title" @click="emit('report-click', item)">
<span v-html="highlightText(item.title)"></span>
</div>
<div class="card-item-time">
<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"
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>
<div class="card-item-time">
<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">
<AreaTag v-for="(val, idx) in item.category" :key="idx" :tagName="val" />
</div>
</div>
</div>
<div class="divider" v-if="index !== hearingData.length - 1"></div>
</div>
<div class="divider" v-if="index !== hearingData.length - 1"></div>
</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="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,28 +52,35 @@
</div> -->
</div>
</div>
<div class="right">
<div class="right" v-loading="loading">
<div class="card-box">
<div class="footer-card" v-for="(item, index) in curFooterList" :key="index"
@click="handleToReportDetail(item)">
<div class="footer-card-top">
<img :src=item.projectCoverImgUrl alt="" />
<template v-if="hasData">
<div class="footer-card" v-for="(item, index) in curFooterList" :key="index"
@click="handleToReportDetail(item)">
<div class="footer-card-top">
<img :src=item.projectCoverImgUrl alt="" />
</div>
<div class="footer-card-title">
<span v-html="highlightText(item.projectNameZh)"></span>
</div>
<div class="footer-card-footer">
<div class="time">{{ formatDate(item.startDate) }}</div>
<div class="from">{{ item.thinktankName }}</div>
</div>
</div>
<div class="footer-card-title">
<span v-html="highlightText(item.projectNameZh)"></span>
</template>
<template v-else-if="!loading">
<div class="right-empty">
<el-empty class="right-el-empty" description="暂无数据" :image-size="100" />
</div>
<div class="footer-card-footer">
<div class="time">{{ formatDate(item.startDate) }}</div>
<div class="from">{{ item.thinktankName }}</div>
</div>
</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,28 +52,35 @@
</div> -->
</div>
</div>
<div class="right">
<div class="right" v-loading="loading">
<div class="card-box">
<div class="footer-card" v-for="(item, index) in curFooterList" :key="index"
@click="handleToReportDetail(item)">
<div class="footer-card-top">
<img :src="item.imageUrl" alt="" />
<template v-if="hasData">
<div class="footer-card" v-for="(item, index) in curFooterList" :key="index"
@click="handleToReportDetail(item)">
<div class="footer-card-top">
<img :src="item.imageUrl" alt="" />
</div>
<div class="footer-card-title">
<span v-html="highlightText(item.name)"></span>
</div>
<div class="footer-card-footer">
<div class="time">{{ formatDate(item.times) }}</div>
<div class="from">{{ item.thinkTankName }}</div>
</div>
</div>
<div class="footer-card-title">
<span v-html="highlightText(item.name)"></span>
</template>
<template v-else-if="!loading">
<div class="right-empty">
<el-empty class="right-el-empty" description="暂无数据" :image-size="100" />
</div>
<div class="footer-card-footer">
<div class="time">{{ formatDate(item.times) }}</div>
<div class="from">{{ item.thinkTankName }}</div>
</div>
</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(
......@@ -415,6 +430,9 @@ const handleGetThinkDynamicsReport = async (payload) => {
thinkTankId,
pageNum: Math.max(0, (currentPage.value || 1)),
pageSize: 10,
// 国会听证会:排序语义与其它资源库相反(正序→desc,倒序→asc)
sortField: "createTime",
sortOrder: sort.value === true ? "desc" : "asc",
domainIds,
startDate,
......@@ -481,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: {
......
......@@ -474,7 +474,7 @@ onMounted(async () => {
display: inline-flex;
position: absolute;
left: 277px;
right: -8px;
bottom: 208px;
background-color: rgba(255, 77, 79, 1);
align-items: center;
......
......@@ -36,26 +36,33 @@
</div>
</div>
<div class="right">
<div class="right" v-loading="loading">
<div class="card-box">
<div class="footer-card" v-for="(item, index) in curFooterList" :key="index"
@click="emit('report-click', item)">
<div class="footer-card-top">
<img :src="item.imageUrl" alt="" />
<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">
<img :src="item.imageUrl" alt="" />
</div>
<div class="footer-card-title">
{{ item.name }}
</div>
<div class="footer-card-footer">
<div class="time">{{ formatDate(item.times) }}</div>
<div class="from">{{ item.thinkTankName }}</div>
</div>
</div>
<div class="footer-card-title">
{{ item.name }}
</template>
<template v-else-if="!loading">
<div class="right-empty">
<el-empty class="right-el-empty" description="暂无数据" :image-size="100" />
</div>
<div class="footer-card-footer">
<div class="time">{{ formatDate(item.times) }}</div>
<div class="from">{{ item.thinkTankName }}</div>
</div>
</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,26 +36,33 @@
</div>
</div>
<div class="right">
<div class="right" v-loading="loading">
<div class="card-box">
<div class="footer-card" v-for="(item, index) in curFooterList" :key="index"
@click="emit('report-click', item)">
<div class="footer-card-top">
<img :src="item.projectCoverImgUrl" alt="" />
<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">
<img :src="item.projectCoverImgUrl" alt="" />
</div>
<div class="footer-card-title">
{{ item.projectNameZh }}
</div>
<div class="footer-card-footer">
<div class="time">{{ formatDate(item.startDate) }}</div>
<div class="from">{{ item.thinktankName }}</div>
</div>
</div>
<div class="footer-card-title">
{{ item.projectNameZh }}
</template>
<template v-else-if="!loading">
<div class="right-empty">
<el-empty class="right-el-empty" description="暂无数据" :image-size="100" />
</div>
<div class="footer-card-footer">
<div class="time">{{ formatDate(item.startDate) }}</div>
<div class="from">{{ item.thinktankName }}</div>
</div>
</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,42 +55,47 @@
</div>
</div>
<div class="right">
<div class="right" v-loading="loading">
<div class="card-box">
<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" />
<div class="card-item-text">
<div class="card-item-title" @click="emit('report-click', item)">
{{ item.titleZh }}
</div>
<div class="card-item-time">
{{ item.testimonyDate + ' · ' + item.thinkTankName + ' · ' + item.committeeZh }}
<img src="../ThinkTankDetail/thinkDynamics/images/image open.png" alt="open icon"
class="card-open-image" />
</div>
<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" />
<div class="card-item-text">
<div class="card-item-title" @click="emit('report-click', item)">
{{ item.titleZh }}
</div>
<div class="card-item-time">
{{ item.testimonyDate + ' · ' + item.thinkTankName + ' · ' + item.committeeZh }}
<img src="../ThinkTankDetail/thinkDynamics/images/image open.png" alt="open icon"
class="card-open-image" />
</div>
<div class="card-item-category" v-if="item.domains">
<div v-for="(value, index) in item.domains" :key="`domain-${index}`">
<AreaTag :key="`cat-${item.id}`" :tagName="value" />
<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>
</div>
</div>
</div>
<div class="divider" v-if="index !== hearingData.length - 1"></div>
</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,54 +36,61 @@
</div>
</div>
<div class="right">
<div class="right" v-loading="loading">
<div class="card-box">
<div class="card-content">
<div v-for="(item, index) in list" :key="item.id ?? index">
<div class="card-item" @click="emit('item-click', item)">
<div class="card-item-img">
<img :src="item.imageUrl" alt="" />
</div>
<div class="card-item-text">
<div class="card-item-title">{{ item.name }}</div>
<div class="card-item-time">
<span class="info-text">{{ formatDate(item.times) }} · {{ item.thinkTankName }} · {{ item.reportName
}}</span>
<div class="card-open-image" @click.stop="handleOpenReportOriginal(item)">
<img src="@/views/thinkTank/ThinkTankDetail/thinkDynamics/images/image open.png" alt="" />
</div>
<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)">
<div class="card-item-img">
<img :src="item.imageUrl" alt="" />
</div>
<div class="card-item-category" v-if="item.tagList && item.tagList.length">
<AreaTag v-for="d in item.tagList" :key="d" :tagName="d" />
</div>
<div class="file-box">
<div class="file" v-for="sv in item.billInfoList" :key="sv.id || sv.name">
<div class="type">法案</div>
<div class="title">{{ sv.name }}</div>
<div class="more" @click.stop="handleBillMoreClick(sv)">
<img src="../assets/images/image-right.png" alt="" />
<div class="card-item-text">
<div class="card-item-title">{{ item.name }}</div>
<div class="card-item-time">
<span class="info-text">{{ formatDate(item.times) }} · {{ item.thinkTankName }} · {{ item.reportName
}}</span>
<div class="card-open-image" @click.stop="handleOpenReportOriginal(item)">
<img src="@/views/thinkTank/ThinkTankDetail/thinkDynamics/images/image open.png" alt="" />
</div>
</div>
<div class="file" v-for="(sv, index) in item.administrativeOrderInfoVOList" :key="index">
<div class="type">政令</div>
<div class="title">{{ sv.content }}</div>
<div class="more" @click.stop="handleAdministrativeMoreClick(sv)">
<img src="../assets/images/image-right.png" alt="" />
</div>
<div class="card-item-category" v-if="item.tagList && item.tagList.length">
<AreaTag v-for="d in item.tagList" :key="d" :tagName="d" />
</div>
<div class="file-box">
<div class="file" v-for="sv in item.billInfoList" :key="sv.id || sv.name">
<div class="type">法案</div>
<div class="title">{{ sv.name }}</div>
<div class="more" @click.stop="handleBillMoreClick(sv)">
<img src="../assets/images/image-right.png" alt="" />
</div>
</div>
<div class="file" v-for="(sv, index) in item.administrativeOrderInfoVOList" :key="index">
<div class="type">政令</div>
<div class="title">{{ sv.content }}</div>
<div class="more" @click.stop="handleAdministrativeMoreClick(sv)">
<img src="../assets/images/image-right.png" alt="" />
</div>
</div>
</div>
</div>
</div>
<div class="divider" v-if="index !== list.length - 1"></div>
</div>
<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;
......@@ -332,7 +358,7 @@ const handleYearGroupChange = (val) => {
.card-item-img {
width: 56px;
width: 156px;
height: 77px;
margin-right: 22px;
flex-shrink: 0;
......
......@@ -209,14 +209,10 @@
</el-carousel>
</OverviewMainBox>
<RiskSignal :list="warningList" @more-click="handleToMoreRiskSignal" postDate="time" name="title"
@item-click="handleRiskSignalItemToManage" />
<RiskSignalOverviewDetailDialog
v-model="isRiskDetailVisible"
:row="riskOverviewDetailRow"
name-field="title"
post-date-field="time"
/>
riskLevel="status" @item-click="handleRiskSignalItemToManage" />
<RiskSignalOverviewDetailDialog v-model="isRiskDetailVisible" :row="riskOverviewDetailRow" name-field="title"
post-date-field="time" risk-level-field="status" />
</div>
<DivideHeader id="position2" class="divide-header" :titleText="'资讯要闻'"></DivideHeader>
<div class="center-center">
......@@ -353,29 +349,26 @@
</div>
</div>
<div class="box8-main">
<div class="box8-main-item">
<div class="box8-item" v-for="(item, index) in box8Data" :key="index"
@click="handleBox8ToDataLibrary(item)">
<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 class="box8-main" :class="{ 'box8-main--empty': !hasBox8Data }">
<template v-if="!hasBox8Data">
<el-empty class="box8-el-empty" description="暂无数据" :image-size="100" />
</template>
<template v-else>
<div class="box8-main-item">
<div class="box8-item" v-for="(item, index) in box8Data" :key="index"
@click="handleBox8ToDataLibrary(item)">
<div class="item-left"
:class="{ itemBold1: index === 0, itemBold2: index === 1, itemBold3: index === 2 }">
{{ index + 1 }}
</div>
<div class="item-center"
:class="{ itemBold1: index === 0, itemBold2: index === 1, itemBold3: index === 2 }">
{{ item.clause }}
</div>
<div class="item-count">{{ item.count + "份报告 >" }}</div>
</div>
<!-- </template>
</el-popover> -->
<!-- <div class="item-right">{{ `${item.count}份报告 >` }}</div> -->
<div class="item-count">{{ item.count + "份报告 >" }}</div>
</div>
</div>
</template>
</div>
</div>
</div>
......@@ -416,12 +409,14 @@
v-model:selectedAreaList="selectedAreaList" :pub-time-list="pubTimeList"
v-model:selectedPubTimeList="selectedPubTimeList" @filter-change="handleThinkTankReportFilterChange"
:cur-footer-list="curFooterList" :total="total" :current-page="currentPage"
:loading="isResourceReportLoading"
@report-click="handleToReportDetail" @page-change="handleCurrentChange" />
<HomeMainFooterSurvey v-else-if="activeCate === '调查项目'" :area-list="areaList"
v-model:selectedAreaList="surveySelectedAreaList" :pub-time-list="pubTimeList"
v-model:selectedPubTimeList="surveySelectedPubTimeList" @filter-change="handleSurveyFilterChange"
:cur-footer-list="surveyFooterList" :total="surveyTotal" :current-page="surveyCurrentPage"
:loading="isResourceSurveyLoading"
@report-click="handleToSurveyProjectView" @page-change="handleSurveyCurrentChange" />
<ThinkTankCongressHearingOverview v-else-if="activeCate === '国会听证会'" :key="`congress-${resourceTabResetKey}`"
......@@ -429,11 +424,13 @@
v-model:selectedAreaList="congressSelectedAreaList"
v-model:selectedPubTimeList="congressSelectedPubTimeList" :total="congressTotal"
:current-page="congressCurrentPage" @filter-change="handleCongressFilterChange"
:loading="isResourceHearingLoading"
@page-change="handleCongressCurrentChange" @report-click="handleToHearingDetail" />
<ThinkTankPolicyAdviceOverview v-else :key="`policy-${resourceTabResetKey}`" :research-type-list="areaList"
:research-time-list="pubTimeList" :list="policyFooterList" :total="policyTotal"
:current-page="policyCurrentPage" :page-size="7" @filter-change="handlePolicyFilterChange"
:loading="isResourcePolicyLoading"
@page-change="handlePolicyCurrentChange" />
</div>
......@@ -462,7 +459,6 @@ import {
getThinkTankList,
getThinkTankRiskSignal,
getThinkTankReportDomainStats,
getThinkTankPolicyIndustry,
getThinkTankDonation,
getAllThinkTankList,
getThinkTankHot,
......@@ -478,6 +474,7 @@ import {
import { getPersonSummaryInfo } from "@/api/common/index";
import getMultiLineChart from "./utils/multiLineChart";
import getPieChart from "./utils/piechart";
import { MUTICHARTCOLORS } from "@/common/constant.js";
import getSankeyChart from "./utils/sankey";
import { getChartAnalysis } from "@/api/aiAnalysis/index";
import defaultNewsIcon from "@/assets/icons/default-icon-news.png";
......@@ -524,33 +521,59 @@ const isRiskDetailVisible = ref(false);
const statCountInfo = ref([]);
const pageSize = ref(15)
const totalAllItem = ref(0)
const isShowAiContentBox5 = ref(true);
// 资源库四个 tab 的列表 loading(右侧居中显示)
const isResourceReportLoading = ref(false);
const isResourceSurveyLoading = ref(false);
const isResourceHearingLoading = ref(false);
const isResourcePolicyLoading = ref(false);
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;
}
};
// 刷新后默认展示「领域分布情况」AI 总结
const isShowAiContentBox6 = ref(true);
const isShowAiContentBox6 = ref(false);
const aiContentBox6 = ref("");
const isBox6InterpretLoading = ref(false);
const box6AiAbortController = ref(null);
const handleSwitchAiContentShowBox6 = (val) => {
isShowAiContentBox6.value = val;
if (val) {
fetchBox6ChartInterpretation();
} else {
if (box6AiAbortController.value) {
box6AiAbortController.value.abort();
box6AiAbortController.value = null;
}
isBox6InterpretLoading.value = false;
}
};
// 刷新后默认展示「智库资金流向」AI 总结
const isShowAiContentBox7 = ref(true);
const isShowAiContentBox7 = ref(false);
const aiContentBox7 = ref("");
const isBox7InterpretLoading = ref(false);
const box7AiAbortController = ref(null);
const handleSwitchAiContentShowBox7 = (val) => {
isShowAiContentBox7.value = val;
if (val) {
fetchBox7ChartInterpretation();
} else {
if (box7AiAbortController.value) {
box7AiAbortController.value.abort();
box7AiAbortController.value = null;
}
isBox7InterpretLoading.value = false;
}
};
const handleGetAllThinkTankList = async () => {
......@@ -985,9 +1008,17 @@ function getDateMonthsAgo(months) {
/** 自然年日期范围(传给 policyIndustryChange) */
const getBox5YearDateRange = year => {
const y = Number(year);
const getYesterdayYmd = () => {
const d = new Date();
d.setDate(d.getDate() - 1);
const yy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${yy}-${mm}-${dd}`;
};
return {
startDate: `${y}-01-01`,
endDate: `${y}-12-31`
endDate: y === new Date().getFullYear() ? getYesterdayYmd() : `${y}-12-31`
};
};
......@@ -1161,6 +1192,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 = {
......@@ -1178,6 +1213,7 @@ const fetchBox5ChartInterpretation = async () => {
const res = await getChartAnalysis(
{ text: JSON.stringify(chartPayload) },
{
signal: box5AiAbortController.value.signal,
onChunk: chunk => {
appendAiInterpretationChunk(aiContentBox5, chunk);
}
......@@ -1186,10 +1222,13 @@ const fetchBox5ChartInterpretation = async () => {
const text = getInterpretationTextFromChartResponse(res);
aiContentBox5.value = text || aiContentBox5.value || "未返回有效解读内容";
} catch (error) {
console.error("图表解读请求失败", error);
aiContentBox5.value = "解读加载失败";
if (error?.name !== "AbortError") {
console.error("图表解读请求失败", error);
aiContentBox5.value = "解读加载失败";
}
} finally {
isBox5InterpretLoading.value = false;
box5AiAbortController.value = null;
}
};
......@@ -1301,6 +1340,10 @@ const fetchBox6ChartInterpretation = async () => {
if (hasValidContent || isBox6InterpretLoading.value) {
return;
}
if (box6AiAbortController.value) {
box6AiAbortController.value.abort();
}
box6AiAbortController.value = new AbortController();
isBox6InterpretLoading.value = true;
aiContentBox6.value = "解读生成中…";
const chartPayload = {
......@@ -1316,6 +1359,7 @@ const fetchBox6ChartInterpretation = async () => {
const res = await getChartAnalysis(
{ text: JSON.stringify(chartPayload) },
{
signal: box6AiAbortController.value.signal,
onChunk: chunk => {
appendAiInterpretationChunk(aiContentBox6, chunk);
}
......@@ -1324,10 +1368,13 @@ const fetchBox6ChartInterpretation = async () => {
const text = getInterpretationTextFromChartResponse(res);
aiContentBox6.value = text || aiContentBox6.value || "未返回有效解读内容";
} catch (error) {
console.error("领域分布图表解读请求失败", error);
aiContentBox6.value = "解读加载失败";
if (error?.name !== "AbortError") {
console.error("领域分布图表解读请求失败", error);
aiContentBox6.value = "解读加载失败";
}
} finally {
isBox6InterpretLoading.value = false;
box6AiAbortController.value = null;
}
};
......@@ -1342,64 +1389,82 @@ const box6TankList = ref([
}
]);
function transformToChartFormat(data) {
// 按 AreaTag 的颜色规则映射到饼图配色(取 tag 的文字色)
const areaTagColorByName = {
"人工智能": "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 fallbackColorPalette = [
"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)"
];
const list = Array.isArray(data) ? data.slice(0, 7) : [];
return list.map((item, index) => ({
name: item.industry,
value: item.amount,
color: areaTagColorByName[item.industry] || fallbackColorPalette[index % fallbackColorPalette.length]
// 兼容 /thinkTankReport/domainStats 返回:[{ year:'2025-Q1', areaList:[{industry,amount}...] }]
const rawList = Array.isArray(data) ? data : [];
const amountByIndustry = new Map();
rawList.forEach((row) => {
const list = Array.isArray(row?.areaList)
? row.areaList
: Array.isArray(row?.industryList)
? row.industryList
: [];
list.forEach((it) => {
const name = String(it?.industry || "").trim();
if (!name) return;
const amt = Number(it?.amount ?? 0) || 0;
amountByIndustry.set(name, (amountByIndustry.get(name) || 0) + amt);
});
});
const all = [...amountByIndustry.entries()]
.map(([name, value]) => ({ name, value }))
.sort((a, b) => (Number(b.value) || 0) - (Number(a.value) || 0));
const total = all.reduce((sum, it) => sum + (Number(it.value) || 0), 0);
const top = all.slice(0, 7);
const rest = all.slice(7);
const topSum = top.reduce((sum, it) => sum + (Number(it.value) || 0), 0);
const out = top.map((item, index) => ({
name: item.name,
value: item.value,
percent: total > 0 ? ((Number(item.value) || 0) / total * 100).toFixed(2) : "0.00",
color: MUTICHARTCOLORS[index % MUTICHARTCOLORS.length]
}));
// 超过 7 个领域时,追加「其他」汇总;<=7 则不展示
if (rest.length > 0 && top.length === 7) {
const otherValue = Math.max(0, total - topSum);
if (otherValue > 0) {
out.push({
name: "其他",
value: otherValue,
percent: total > 0 ? (otherValue / total * 100).toFixed(2) : "0.00",
color: MUTICHARTCOLORS[out.length % MUTICHARTCOLORS.length]
});
}
}
return out;
}
// 政策建议领域分布
const handleGetThinkTankPolicyIndustry = async () => {
const params = {
year: box6selectetedYear.value
const getYesterdayYmd = () => {
const d = new 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}`;
};
const buildBox6DateRange = () => {
const y = Number(box6selectetedYear.value);
const currentY = new Date().getFullYear();
return {
startDate: `${y}-01-01`,
endDate: y === currentY ? getYesterdayYmd() : `${y}-12-31`
};
try {
const res = await getThinkTankPolicyIndustry(params);
};
console.log("政策建议领域分布", res);
// 领域分布情况(/thinkTankReport/domainStats):前端汇总项数与占比,展示前 7 + 其他
const handleGetThinkTankDomainStatsForBox6 = async () => {
try {
const range = buildBox6DateRange();
const res = await getThinkTankReportDomainStats(range);
console.log("领域分布情况(domainStats)", res);
if (res.code === 200 && res.data) {
box6Data.value = transformToChartFormat(res.data);
console.log(transformToChartFormat(res.data), "datadatadata");
} else {
box6Data.value = [];
}
} catch (error) {
console.error("获取政策建议领域分布error", error);
console.error("获取领域分布情况(domainStats)error", error);
box6Data.value = [];
}
};
......@@ -1415,7 +1480,19 @@ const renderBox6Chart = () => {
const selectParam = {
moduleType: '科技智库报告',
key: 2,
selectedDate: JSON.stringify([box6selectetedYear.value + '-01-01', box6selectetedYear.value + '-12-31']),
selectedDate: JSON.stringify([
box6selectetedYear.value + "-01-01",
Number(box6selectetedYear.value) === new Date().getFullYear()
? (() => {
const d = new Date();
d.setDate(d.getDate() - 1);
const yy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${yy}-${mm}-${dd}`;
})()
: box6selectetedYear.value + "-12-31"
]),
orgnizationName: box6selectetedTank.value,
}
const box6Chart = getPieChart(pieData);
......@@ -1430,7 +1507,7 @@ const handleBox6AreaChange = () => {
const handleBox6 = async () => {
box6selectetedArea.value = "全部领域";
aiContentBox6.value = "";
await handleGetThinkTankPolicyIndustry();
await handleGetThinkTankDomainStatsForBox6();
renderBox6Chart();
// 若 AI 面板已打开,让解读在首次加载时自动生成
if (isShowAiContentBox6.value) {
......@@ -1459,6 +1536,21 @@ const hasBox7ChartData = computed(() => {
function transformDataToSankey(inputData) {
const nodes = [];
const links = [];
const colorByName = new Map();
let colorCursor = 0;
const getNodeColor = (name) => {
const key = String(name || "").trim();
if (!key) {
return MUTICHARTCOLORS[0];
}
if (colorByName.has(key)) {
return colorByName.get(key);
}
const c = MUTICHARTCOLORS[colorCursor % MUTICHARTCOLORS.length];
colorCursor += 1;
colorByName.set(key, c);
return c;
};
// 遍历输入数据
inputData.forEach(item => {
......@@ -1466,7 +1558,7 @@ function transformDataToSankey(inputData) {
// 添加智库节点(如果尚未添加)
if (!nodes.some(node => node.name === thinkTankName)) {
nodes.push({ name: thinkTankName });
nodes.push({ name: thinkTankName, itemStyle: { color: getNodeColor(thinkTankName) } });
}
// 遍历捐赠来源
......@@ -1477,12 +1569,12 @@ function transformDataToSankey(inputData) {
// 添加捐赠机构节点(如果尚未添加)
if (!nodes.some(node => node.name === institution)) {
nodes.push({ name: institution });
nodes.push({ name: institution, itemStyle: { color: getNodeColor(institution) } });
}
// 如果存在二级机构,也添加二级机构节点(如果尚未添加)
if (secondInstitution && !nodes.some(node => node.name === secondInstitution)) {
nodes.push({ name: secondInstitution });
nodes.push({ name: secondInstitution, itemStyle: { color: getNodeColor(secondInstitution) } });
}
// 添加链接
......@@ -1570,6 +1662,10 @@ const fetchBox7ChartInterpretation = async () => {
if (hasValidContent || isBox7InterpretLoading.value) {
return;
}
if (box7AiAbortController.value) {
box7AiAbortController.value.abort();
}
box7AiAbortController.value = new AbortController();
isBox7InterpretLoading.value = true;
aiContentBox7.value = "解读生成中…";
const chartPayload = {
......@@ -1586,6 +1682,7 @@ const fetchBox7ChartInterpretation = async () => {
const res = await getChartAnalysis(
{ text: JSON.stringify(chartPayload) },
{
signal: box7AiAbortController.value.signal,
onChunk: chunk => {
appendAiInterpretationChunk(aiContentBox7, chunk);
}
......@@ -1594,10 +1691,13 @@ const fetchBox7ChartInterpretation = async () => {
const text = getInterpretationTextFromChartResponse(res);
aiContentBox7.value = text || aiContentBox7.value || "未返回有效解读内容";
} catch (error) {
console.error("智库资金流向图表解读请求失败", error);
aiContentBox7.value = "解读加载失败";
if (error?.name !== "AbortError") {
console.error("智库资金流向图表解读请求失败", error);
aiContentBox7.value = "解读加载失败";
}
} finally {
isBox7InterpretLoading.value = false;
box7AiAbortController.value = null;
}
};
......@@ -1645,6 +1745,11 @@ const box8Data = ref([
// }
]);
const hasBox8Data = computed(() => {
const list = box8Data.value;
return Array.isArray(list) && list.length > 0;
});
const box8selectetedYear = ref(1);
const box8YearList = ref([
{
......@@ -1795,6 +1900,10 @@ const handleThinkTankReportFilterChange = () => {
};
const pubTimeList = ref([
{
id: 2026,
name: "2026"
},
{
id: 2025,
name: "2025"
......@@ -1866,7 +1975,7 @@ const handleResourceLibrarySortChange = () => {
} else if (activeCate.value === "政策建议") {
handleGetThinkTankPolicyAdvice();
} else if (activeCate.value === "国会听证会") {
return;
handleGetThinkTankHearings();
} else {
handleGetetThinkTankReport();
}
......@@ -1881,6 +1990,7 @@ const toggleResourceLibrarySortPrefix = () => {
handleGetThinkTankPolicyAdvice();
} else if (activeCate.value === "国会听证会") {
congressResourceSort.value = congressResourceSort.value === true ? false : true;
handleGetThinkTankHearings();
} else {
sort.value = sort.value === true ? false : true;
handleGetetThinkTankReport();
......@@ -1908,6 +2018,26 @@ const congressSelectedPubTimeList = ref([RESOURCE_FILTER_ALL_TIME]);
const congressCurrentPage = ref(1);
const congressTotal = ref(0);
const scrollToResourceLibraryHeader = () => {
const header = document.getElementById("position4");
const wrapper = containerRef.value;
if (!header || !wrapper) return;
const wrapperRect = wrapper.getBoundingClientRect();
const headerRect = header.getBoundingClientRect();
// 滚动到「资源库」标题上方一点点(在 home-wrapper 这个滚动容器内)
const delta = headerRect.top - wrapperRect.top;
const targetTop = (wrapper.scrollTop || 0) + delta - 20;
wrapper.scrollTo({ top: Math.max(0, targetTop), behavior: "smooth" });
};
const scrollToResourceLibraryHeaderAfterRender = async () => {
// 等待分页数据渲染完成(对齐风险信号管理:nextTick 后再滚动)
await nextTick();
await new Promise((resolve) => requestAnimationFrame(resolve));
scrollToResourceLibraryHeader();
};
const handleCongressFilterChange = () => {
congressCurrentPage.value = 1;
handleGetThinkTankHearings();
......@@ -1916,6 +2046,8 @@ const handleCongressFilterChange = () => {
const handleCongressCurrentChange = (page) => {
congressCurrentPage.value = page;
handleGetThinkTankHearings();
// 分页后让滚动条回到资源库标题上方一点点
scrollToResourceLibraryHeaderAfterRender();
};
const handleSurveyFilterChange = () => {
......@@ -1926,9 +2058,11 @@ const handleSurveyFilterChange = () => {
const handleSurveyCurrentChange = page => {
surveyCurrentPage.value = page;
handleGetThinkTankSurvey();
scrollToResourceLibraryHeaderAfterRender();
};
const handleGetThinkTankSurvey = async () => {
isResourceSurveyLoading.value = true;
const { startDate, endDate } = getResourceLibraryReportDateRangeFromTimeSelection(
stripAllTimeForRequest(surveySelectedPubTimeList.value),
(pubTimeList.value || []).map((x) => x.id)
......@@ -1961,9 +2095,12 @@ const handleGetThinkTankSurvey = async () => {
}
} catch (error) {
console.error("获取调查项目 error", error);
} finally {
isResourceSurveyLoading.value = false;
}
};
const handleGetThinkTankHearings = async () => {
isResourceHearingLoading.value = true;
try {
const { startDate, endDate } = getResourceLibraryReportDateRangeFromTimeSelection(
stripAllTimeForRequest(congressSelectedPubTimeList.value),
......@@ -1973,6 +2110,9 @@ const handleGetThinkTankHearings = async () => {
pageNum: congressCurrentPage.value,
pageSize: 10,
sortFun: congressResourceSort.value === true,
// 国会听证会:排序语义与其它资源库相反(正序→desc,倒序→asc)
sortField: "createTime",
sortOrder: congressResourceSort.value === true ? "desc" : "asc",
domainIds: (() => {
const areas = stripAllAreaForRequest(congressSelectedAreaList.value);
const allAreaIds = (areaList.value || []).map((a) => a.id);
......@@ -1994,6 +2134,8 @@ const handleGetThinkTankHearings = async () => {
}
} catch (error) {
console.error("获取调查项目 error", error);
} finally {
isResourceHearingLoading.value = false;
}
};
// ===== 政策建议:独立状态(不影响智库报告/调查项目)=====
......@@ -2016,9 +2158,11 @@ const handlePolicyFilterChange = payload => {
const handlePolicyCurrentChange = page => {
policyCurrentPage.value = page;
handleGetThinkTankPolicyAdvice();
scrollToResourceLibraryHeaderAfterRender();
};
const handleGetThinkTankPolicyAdvice = async () => {
isResourcePolicyLoading.value = true;
const strippedPolicyYears = stripAllTimeForRequest(policySelectedYearIds.value);
const allPubTimeIds = (pubTimeList.value || []).map((x) => x.id);
/** 与智库报告一致:仅「全部时间」或选满所有具体年份 → 不按 years 狭义过滤 */
......@@ -2056,7 +2200,8 @@ const handleGetThinkTankPolicyAdvice = async () => {
name: item.title,
reportName: item.reportName,
times: item.date,
imageUrl: item.coverImage,
// 列表图片:优先 coverImageUrl,兼容旧字段 coverImage
imageUrl: item.coverImageUrl || item.coverImage,
tagList: item.domains || [],
thinkTankName: item.thinkTankName ?? null,
reportId: item.reportId ?? item.id,
......@@ -2079,6 +2224,8 @@ const handleGetThinkTankPolicyAdvice = async () => {
policyTotal.value = 0;
console.error("获取政策建议 error", error);
ElMessage.warning("获取政策建议失败");
} finally {
isResourcePolicyLoading.value = false;
}
};
// 处理页码改变事件
......@@ -2086,6 +2233,7 @@ const handleCurrentChange = page => {
console.log(page, "pagepagepage");
currentPage.value = page;
handleGetetThinkTankReport();
scrollToResourceLibraryHeaderAfterRender();
};
function arrayToString(arr) {
return arr.reduce((acc, item) => {
......@@ -2098,6 +2246,7 @@ function arrayToString(arr) {
//获取智库报告
const handleGetetThinkTankReport = async () => {
isResourceReportLoading.value = true;
const { startDate, endDate } = getResourceLibraryReportDateRangeFromTimeSelection(
stripAllTimeForRequest(selectedPubTimeList.value),
(pubTimeList.value || []).map((x) => x.id)
......@@ -2128,6 +2277,8 @@ const handleGetetThinkTankReport = async () => {
}
} catch (error) {
console.error("获取智库报告error", error);
} finally {
isResourceReportLoading.value = false;
}
};
......@@ -2297,9 +2448,8 @@ onMounted(async () => {
handleGetNewReport();
handleGetThinkTankRiskSignal();
// 先拉到图表数据,再打开 AI 面板并触发解读,避免初始为空导致“无内容”
// 先把图表数据准备好;AI 解读仅在用户悬停打开面板时触发
await handleBox5(box5selectetedYear.value);
handleSwitchAiContentShowBox5(true);
// 先把图表数据准备好,避免用户悬浮太快触发解读但数据未就绪
await handleBox6();
await handleBox7();
......@@ -2639,7 +2789,7 @@ onBeforeUnmount(() => {
display: inline-flex;
position: absolute;
left: 233px;
right: -8px;
bottom: 208px;
background-color: rgba(255, 77, 79, 1);
align-items: center;
......@@ -4165,6 +4315,22 @@ onBeforeUnmount(() => {
height: 412px;
&.box8-main--empty {
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
:deep(.el-empty__image) {
margin-bottom: 0;
}
}
.box8-el-empty {
padding: 0;
margin: 0;
}
.box8-main-item {
margin: 0 auto;
margin-top: 5px;
......@@ -4612,6 +4778,7 @@ onBeforeUnmount(() => {
<style lang="scss">
/* 弹窗打开时禁用轮播箭头穿透(样式仅作用于智库页结构) */
.home-main.is-risk-detail-open {
.box1-left,
.box1-right {
pointer-events: none;
......
......@@ -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) {
return {
r: parseInt(match[1]),
g: parseInt(match[2]),
b: parseInt(match[3]),
a: parseFloat(match[4])
};
const parseHexToRgb = (hex) => {
const h = String(hex || '').replace('#', '').trim()
if (h.length !== 6) return { r: 0, g: 0, b: 0 }
return {
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,46 +70,33 @@ const getMultiLineChart = (data) => {
},
/* 顶部预留足够空间:多行图例 + Y 轴标题「数量」在纵轴顶端,避免与图例重合 */
grid: {
top: '34%',
top: 68,
right: '3%',
bottom: '5%',
left: '2%',
containLabel: true
},
legend: [
{
show: true,
type: 'plain',
data: legendFirstLine,
top: 8,
left: 'center',
icon: 'circle',
textStyle: {
fontFamily: 'Source Han Sans CN',
fontWeight: 400,
fontSize: 14,
lineHeight: 24,
letterSpacing: 0,
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'
}
legend: {
show: true,
type: 'plain',
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,
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 }
}
}
})
......@@ -37,10 +38,15 @@ const getPieChart = (data) => {
alignTo: 'edge',
formatter: params => {
const name = params.name || "";
const value = params.value ?? "";
const percent = params.percent != null ? Math.round(params.percent) : 0;
const value = Number(params.value ?? 0) || 0;
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| ${percent}%}`;
// 第二行:数值 + 百分比(同一行、同一文字样式),保持与旧版一致的两行结构
return `{name|${name}}\n{time|${value}${percent}%}`;
},
minMargin: 5,
edgeDistance: 10,
......
......@@ -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)
if (!Number.isFinite(n)) return '0.00万'
return `${n.toFixed(2)}万`
}
const option = {
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: 'rgba(234, 236, 238, 1)',
borderWidth: 1,
textStyle: {
color: 'rgb(59, 65, 75)',
fontFamily: 'Microsoft YaHei',
fontSize: 14,
lineHeight: 22
},
formatter: function (params) {
// 仅对连线展示资金金额;节点保持默认名称提示
if (params?.dataType === 'edge') {
const amount = formatAmountWan(params?.data?.value)
return `资金金额:${amount}`
}
return params?.name ?? ''
}
},
series: {
type: 'sankey',
layout: 'none',
......@@ -9,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 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论