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

feat:智库全选功能选择框逻辑统一,高度自适应,智库部分图表格式修改,概览页近期美国智库机构发布涉华报告数量汇总样式修改

上级 09b8b813
......@@ -36,6 +36,47 @@ function parseChartInterpretationArray(buffer) {
throw new Error("无法解析图表解读 JSON 数组");
}
/**
* 从数组结果中提取可展示的解读文本
* @param {unknown[]} arr
* @returns {string}
*/
function pickInterpretationText(arr) {
if (!Array.isArray(arr) || arr.length === 0) {
return "";
}
const first = arr[0] || {};
return (
first["解读"] ||
first["interpretation"] ||
first["analysis"] ||
first["content"] ||
""
);
}
/**
* 从非标准 JSON 文本中兜底提取“解读”字段(兼容单引号/双引号)
* 示例:
* [{'图表标题': '数量变化趋势', '解读': 'xxx'}]
* [{"图表标题":"数量变化趋势","解读":"xxx"}]
* @param {string} text
* @returns {string}
*/
function extractInterpretationFromLooseText(text) {
const raw = String(text || "");
if (!raw) {
return "";
}
const reg =
/["']解读["']\s*:\s*["']([\s\S]*?)["']\s*(?:[,}\]])/;
const m = raw.match(reg);
if (!m || !m[1]) {
return "";
}
return String(m[1]).replace(/\\n/g, "\n").trim();
}
/**
* 图表解读(SSE 流式)
* @param {object} data - 请求体
......@@ -44,9 +85,15 @@ function parseChartInterpretationArray(buffer) {
* @returns {Promise<{data: unknown[]}>}
*/
export function getChartAnalysis(data, options = {}) {
const { onChunk } = options;
const onDelta =
typeof options?.onChunk === "function"
? options.onChunk
: typeof options?.onInterpretationDelta === "function"
? options.onInterpretationDelta
: null;
return new Promise((resolve, reject) => {
let buffer = "";
let latestInterpretation = "";
let settled = false;
const abortController = new AbortController();
......@@ -99,6 +146,18 @@ export function getChartAnalysis(data, options = {}) {
if (msg && typeof msg === "object" && "text" in msg) {
chunk = String(msg.text ?? "");
buffer += chunk;
} else if (msg && Array.isArray(msg.data)) {
const arr = msg.data;
const interpretation = pickInterpretationText(arr);
if (interpretation) {
latestInterpretation = interpretation;
if (onDelta) {
onDelta(interpretation);
}
}
safeResolve({ data: arr });
abortController.abort();
return;
} else {
chunk = raw;
buffer += raw;
......@@ -108,9 +167,18 @@ export function getChartAnalysis(data, options = {}) {
buffer += raw;
}
// 兜底:非标准 JSON(如单引号 Python 风格)时,尝试直接从文本提取“解读”
const looseInterpretation = extractInterpretationFromLooseText(raw);
if (looseInterpretation) {
latestInterpretation = looseInterpretation;
safeResolve({ data: [{ 解读: looseInterpretation }] });
abortController.abort();
return;
}
// 每收到一条消息即回调,用于流式渲染
if (chunk && typeof onChunk === "function") {
onChunk(chunk);
if (chunk && onDelta) {
onDelta(chunk);
}
// 如果 buffer 已经拼完 markdown code fence,则提前解析并中断连接
......@@ -118,6 +186,10 @@ export function getChartAnalysis(data, options = {}) {
if (trimmed.endsWith("```")) {
try {
const arr = parseChartInterpretationArray(trimmed);
const interpretation = pickInterpretationText(arr);
if (interpretation) {
latestInterpretation = interpretation;
}
safeResolve({ data: arr });
abortController.abort();
} catch (_) { }
......@@ -126,8 +198,22 @@ export function getChartAnalysis(data, options = {}) {
onclose: () => {
try {
const arr = parseChartInterpretationArray(buffer);
const interpretation = pickInterpretationText(arr);
if (interpretation) {
latestInterpretation = interpretation;
}
safeResolve({ data: arr });
} catch (e) {
// 兜底:整体 buffer 不是标准 JSON(如单引号)时直接提取“解读”
const looseInterpretation = extractInterpretationFromLooseText(buffer);
if (looseInterpretation) {
safeResolve({ data: [{ 解读: looseInterpretation }] });
return;
}
if (latestInterpretation) {
safeResolve({ data: [{ 解读: latestInterpretation }] });
return;
}
safeReject(e);
}
},
......
......@@ -1156,6 +1156,15 @@
| -------- | -------- | ----- | -------- | -------- | ------ |
|areas|区域名称列表|query|false|array|string|
|researchTypeIds|研究类型ID列表|query|false|array|string|
|domainIds|科技领域 ID 列表(逗号分隔)|query|false|string||
|startDate|发布时间起 YYYY-MM-DD(与政策追踪发布时间逻辑一致)|query|false|string||
|endDate|发布时间止 YYYY-MM-DD|query|false|string||
|category|分类(如调查项目)|query|false|string||
|pageNum|页码|query|false|integer||
|pageSize|每页条数|query|false|integer||
|sortFun|排序|query|false|boolean||
|thinkTankId|智库 ID(详情页动态列表限定当前智库)|query|false|string||
|keyword|关键词搜索(智库动态)|query|false|string||
|token|Token Request Header|header|false|string||
......
......@@ -87,7 +87,11 @@ export function getHylyList() {
}
//获取智库报告
/**
* 智库概览/智库动态-智库报告、调查项目
* GET /api/thinkTankOverview/report
* 常用 query:pageNum, pageSize, sortFun, domainIds, startDate, endDate, category(调查项目), thinkTankId(详情页), keyword(动态搜索)
*/
export function getThinkTankReport(params) {
return request({
method: 'GET',
......@@ -240,18 +244,12 @@ export function getThinkTankInfoBranch(params) {
})
}
//获取经费来源统计
export function getThinkTankFundsTotal(params) {
return request({
method: 'GET',
url: `/api/thinkTankInfo/fundsTotal/${params}`,
})
}
//获取经费来源
export function getThinkTankFundsSource(params) {
return request({
method: 'GET',
url: `/api/thinkTankInfo/fundsSource/${params}`,
url: `/api/thinkTankInfo/fundsStatistics/${params}`,
})
}
......@@ -265,9 +263,17 @@ export function getThinkTankResearchAreae(params) {
//获取核心研究人员
export function getThinkTankPerson(params) {
const { thinkTankId, currentPage, pageSize } = params
return request({
method: 'GET',
url: `/api/thinkTankInfo/person/${params}`,
url: `/api/thinkTankInfo/person/page`,
params: {
currentPage,
pageNum: currentPage,
page: currentPage,
pageSize,
thinkTankId
}
})
}
......
......@@ -85,7 +85,7 @@
</div>
</div>
<div class="box1-middle">
<div class="box1-item" v-for="(item, index) in curFooterList" :key="item.id">
<div class="box1-item" v-for="(item, index) in curFooterList" :key="item.id" @click="handleToReportDetail(item)">
<div class="left">
<img :src=item.imageUrl alt="">
</div>
......@@ -93,9 +93,9 @@
<div class="right-header"> {{ item.name }}</div>
<div class="right-footer">
<div class="time">{{ item.times }}</div>
<div class="text-image">
<div class="text-image think-tank-link" @click.stop="handleToReportDetail(item)">
<div class="image">
<img src="../assets/images/rand-image.png" alt="" />
<img :src=item.logoUrl alt="" />
</div>
<div class="text">
{{ item.thinkTankName }}
......@@ -105,8 +105,8 @@
</div>
<div class="image-change">
<img src="../assets/images/plus.png" alt="" v-if="!selectedIds.has(item.id)"
@click="toggleSelected(item.id, item)" />
<img src="../assets/images/Minus.png" alt="" v-else @click="toggleSelected(item.id, item)" />
@click.stop="toggleSelected(item.id, item)" />
<img src="../assets/images/Minus.png" alt="" v-else @click.stop="toggleSelected(item.id, item)" />
</div>
</div>
</div>
......@@ -125,7 +125,8 @@
<div class="right-text-analysis">{{ "共选择" }}{{ selectedReportList.length }}{{ "篇智库报告" }}</div>
</div>
<div class="box1-middle-analysis">
<div class="box1-item" v-for="(item, index) in selectedReportList" :key="item.id || index">
<div class="box1-item" v-for="(item, index) in selectedReportList" :key="item.id || index"
@click="handleToReportDetail(item)">
<div class="left">
<img :src=item.imageUrl alt="">
</div>
......@@ -133,9 +134,9 @@
<div class="right-header"> {{ item.name }}</div>
<div class="right-footer">
<div class="time">{{ item.times }}</div>
<div class="text-image">
<div class="text-image think-tank-link" @click.stop="handleToReportDetail(item)">
<div class="image">
<img src="../assets/images/rand-image.png" alt="" />
<img :src=item.logoUrl alt="" />
</div>
<div class="text">
{{ item.thinkTankName }}
......@@ -145,8 +146,8 @@
</div>
<div class="image-change">
<img src="../assets/images/plus.png" alt="" v-if="!selectedIds.has(item.id)"
@click="toggleSelected(item.id, item)" />
<img src="../assets/images/Minus.png" alt="" v-else @click="toggleSelected(item.id, item)" />
@click.stop="toggleSelected(item.id, item)" />
<img src="../assets/images/Minus.png" alt="" v-else @click.stop="toggleSelected(item.id, item)" />
</div>
</div>
</div>
......@@ -285,7 +286,7 @@ import router from '@/router';
import { onMounted, ref, computed, reactive, nextTick } from "vue";
import AnalysisBox from "@/components/base/boxBackground/analysisBox.vue"
import AnalysisResultBox from "./boxBackground/analysisBox.vue"
import { getThinkTankReport, getHylyList, getThinkDynamicsReport, postReportDomainViewAnalysis } from "@/api/thinkTank/overview";
import { getThinkTankReport, getHylyList, postReportDomainViewAnalysis } from "@/api/thinkTank/overview";
const sort = ref("");
const searchPolicy = ref("");
const isBox2 = ref(true)
......@@ -521,6 +522,18 @@ const handleCurrentChange = page => {
currentPage.value = page;
handleGetetThinkTankReport(page);
};
const handleToReportDetail = (item) => {
if (!item?.id) return;
window.sessionStorage.setItem("curTabName", item.name);
const route = router.resolve({
name: "ReportDetail",
params: {
id: item.id
}
});
window.open(route.href, "_blank");
};
//获取行业领域字典
// getHylyList
const handleGetHylyList = async () => {
......@@ -536,8 +549,9 @@ const handleGetHylyList = async () => {
};
//获取智库报告
const handleGetetThinkTankReport = async (page = currentPage.value) => {
const id = router.currentRoute?.value?.params?.id || "";
const getDateYearsAgo = years => {
const thinkTankId = router.currentRoute?.value?.params?.id || "";
const getDateYearsAgo = (years) => {
const d = new Date();
d.setFullYear(d.getFullYear() - Number(years || 1));
const y = d.getFullYear();
......@@ -545,20 +559,32 @@ const handleGetetThinkTankReport = async (page = currentPage.value) => {
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
};
const getTodayYmd = () => {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
};
const domainIds = arrayToString(selectedAreaList.value);
const keyword = (searchPolicy.value || "").trim();
const params = {
id,
startDate: getDateYearsAgo(selectedYears.value),
// 不传 authorName(避免后端按空字符串筛选)
currentPage: Number(page) - 1, // 1-based
thinkTankId,
pageNum: Number(page),
pageSize: pageSize,
researchTypeIds: arrayToString(selectedAreaList.value),
searchText: (searchPolicy.value || "").trim(),
sortFun: false,
domainIds,
startDate: getDateYearsAgo(selectedYears.value),
endDate: getTodayYmd(),
keyword: keyword || undefined,
};
try {
// 先清空,避免视觉上看起来没变化
curFooterList.value = [];
const res = await getThinkDynamicsReport(params);
const res = await getThinkTankReport(params);
console.log("智库报告", res);
if (res.code === 200 && res.data) {
curFooterList.value = res.data.content;
......@@ -621,6 +647,10 @@ onMounted(async () => {
}
}
.think-tank-link {
cursor: pointer;
}
.box {
display: flex;
gap: 16px;
......
......@@ -330,11 +330,11 @@ onMounted(async () => {
.box1 {
margin-top: 19px;
width: 1600px;
height: 1173px;
.box1-main {
margin-top: 8px;
height: 1097px;
min-height: 778px;
padding-left: 21px;
padding-right: 50px;
padding-bottom: 21px;
......@@ -421,7 +421,7 @@ onMounted(async () => {
.item-box {
width: 506px;
height: 100%;
min-height: 639.2px;
border-top: 1px solid rgb(234, 236, 238);
.item {
......
import * as echarts from 'echarts'
/** 政策追踪「研究领域变化趋势」图例分页:每页条数(与概览数量变化趋势逻辑一致,条数按产品要求为 4) */
export const POLICY_TRACKING_LEGEND_PAGE_SIZE = 4
const colorList = [
'rgba(5, 95, 194, 1)',
'rgba(19, 168, 168, 1)',
......@@ -35,22 +32,16 @@ const parseRgba = (colorStr) => {
/**
* @param {{ title: unknown[], data: Array<{ name: string, value: unknown[], color?: string }> }} chartInput
* @param {{ legendShowCount?: number, legendPageIndex?: number }} [options]
*/
const getMultiLineChart = (chartInput, options = {}) => {
const getMultiLineChart = (chartInput) => {
const title = chartInput.title
const series = chartInput.data || []
const legendShowCount =
typeof options.legendShowCount === 'number' && options.legendShowCount > 0
? options.legendShowCount
: POLICY_TRACKING_LEGEND_PAGE_SIZE
const rawPageIndex = Number(options.legendPageIndex) || 0
const allNames = series.map((item) => item.name)
const pageCount = Math.max(1, Math.ceil(allNames.length / legendShowCount))
const legendPageIndex = Math.min(Math.max(0, rawPageIndex), pageCount - 1)
const legendStart = legendPageIndex * legendShowCount
const legendData = allNames.slice(legendStart, legendStart + legendShowCount)
const 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
......@@ -91,31 +82,71 @@ const getMultiLineChart = (chartInput, options = {}) => {
},
/* 贴满 #box3Chart:四边 0,由 containLabel 在网格内为轴文字留位,避免左侧/底部大块留白 */
grid: {
top: 50,
top: 92,
right: 10,
bottom: 0,
left: 20,
containLabel: true
},
legend: {
show: true,
type: 'plain',
data: legendData,
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)'
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
},
itemWidth: 12,
itemHeight: 12
},
{
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
},
{
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
}
],
color: colorList,
xAxis: [
{
......@@ -142,6 +173,16 @@ const getMultiLineChart = (chartInput, options = {}) => {
yAxis: [
{
type: 'value',
name: '数量',
nameLocation: 'end',
nameGap: 20,
nameTextStyle: {
color: 'rgb(132, 136, 142)',
fontFamily: 'Source Han Sans CN',
fontWeight: 400,
fontSize: 11,
padding: [0, 0, 0, -20] // 👈 这个是左移 4px(上、右、下、左)
},
splitNumber: 4,
axisLabel: {
color: 'rgb(132, 136, 142)',
......
......@@ -21,16 +21,17 @@
<div class="title">{{ "科技领域" }}</div>
</div>
<div class="select-main">
<div class="checkbox-group">
<el-checkbox class="filter-checkbox" :model-value="isGroupAllSelected(researchTypeIds)"
@change="val => handleToggleAll(val, researchTypeIds)">
全部领域
<el-checkbox-group
class="checkbox-group"
:model-value="selectedResearchIds"
@change="handleAreaGroupChange">
<el-checkbox class="filter-checkbox" :label="RESOURCE_FILTER_ALL_AREA">
{{ RESOURCE_FILTER_ALL_AREA }}
</el-checkbox>
<el-checkbox v-for="type in researchTypeList" :key="type.id" v-model="selectedResearchIds" :label="type.id"
class="filter-checkbox" @change="handleGetThinkDynamicsReport()">
<el-checkbox v-for="type in researchTypeList" :key="type.id" class="filter-checkbox" :label="type.id">
{{ type.name }}
</el-checkbox>
</div>
</el-checkbox-group>
</div>
</div>
<div class="select-time-box">
......@@ -39,16 +40,17 @@
<div class="title">{{ "发布时间" }}</div>
</div>
<div class="select-main">
<div class="checkbox-group">
<el-checkbox class="filter-checkbox" :model-value="isGroupAllSelected(researchTimeIds)"
@change="val => handleToggleAll(val, researchTimeIds)">
全部时间
<el-checkbox-group
class="checkbox-group"
:model-value="selectedResearchTimeIds"
@change="handleTimeGroupChange">
<el-checkbox class="filter-checkbox" :label="RESOURCE_FILTER_ALL_TIME">
{{ RESOURCE_FILTER_ALL_TIME }}
</el-checkbox>
<el-checkbox v-for="type in researchTimeList" :key="type.id" v-model="selectedResearchTimeIds"
:label="type.id" class="filter-checkbox" @change="handleGetThinkDynamicsReport()">
<el-checkbox v-for="type in researchTimeList" :key="type.id" class="filter-checkbox" :label="type.id">
{{ type.name }}
</el-checkbox>
</div>
</el-checkbox-group>
</div>
<!-- <div class="input-main">
<el-input placeholder="输入作者名字" v-model="author" @input="handleGetThinkDynamicsReport()" />
......@@ -63,7 +65,7 @@
<img :src="item.imageUrl" alt="" />
</div>
<div class="footer-card-title">
{{ item.name }}
<span v-html="highlightText(item.name)"></span>
</div>
<div class="footer-card-footer">
<div class="time">{{ item.times }}</div>
......@@ -84,7 +86,14 @@
</div>
</template>
<script setup>
import { computed, ref, toRefs, watch } from "vue";
import { ref, toRefs, watch } from "vue";
import {
RESOURCE_FILTER_ALL_AREA,
RESOURCE_FILTER_ALL_TIME,
normalizeExclusiveAllOption,
stripAllAreaForRequest,
stripAllTimeForRequest
} from "../../../utils/resourceLibraryFilters";
const props = defineProps({
researchTypeList: {
......@@ -114,6 +123,10 @@ const props = defineProps({
currentPage: {
type: Number,
default: 1
},
searchKeyword: {
type: String,
default: ""
}
});
......@@ -127,43 +140,58 @@ const emit = defineEmits([
// 解构 props,保持模板里变量名不变
const { researchTypeList, researchTimeList, curFooterList, total, currentPage } = toRefs(props);
const selectedResearchIds = ref([]);
const selectedResearchTimeIds = ref([]);
const selectedResearchIds = ref([RESOURCE_FILTER_ALL_AREA]);
const selectedResearchTimeIds = ref([RESOURCE_FILTER_ALL_TIME]);
const escapeHtml = (text) => {
return String(text ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
};
const escapeRegExp = (text) => {
return String(text ?? "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
};
const highlightText = (text) => {
const safeText = escapeHtml(text);
const keyword = (props.searchKeyword || "").trim();
if (!keyword) return safeText;
const pattern = new RegExp(`(${escapeRegExp(keyword)})`, "gi");
return safeText.replace(pattern, '<span class="keyword-highlight">$1</span>');
};
// 父组件更新时同步到子组件
watch(
() => props.selectedFilters,
val => {
selectedResearchIds.value = val?.researchTypeIds ? [...val.researchTypeIds] : [];
selectedResearchTimeIds.value = val?.researchTimeIds ? [...val.researchTimeIds] : [];
selectedResearchIds.value =
val?.researchTypeIds?.length > 0
? [...val.researchTypeIds]
: [RESOURCE_FILTER_ALL_AREA];
selectedResearchTimeIds.value =
val?.researchTimeIds?.length > 0
? [...val.researchTimeIds]
: [RESOURCE_FILTER_ALL_TIME];
},
{ immediate: true, deep: true }
);
const buildSelectedFiltersPayload = () => ({
researchTypeIds: [...selectedResearchIds.value],
researchTimeIds: [...selectedResearchTimeIds.value],
researchTypeIds: stripAllAreaForRequest(selectedResearchIds.value),
researchTimeIds: stripAllTimeForRequest(selectedResearchTimeIds.value),
researchHearingIds: []
});
const researchTypeIds = computed(() => (researchTypeList.value || []).map(item => item.id));
const researchTimeIds = computed(() => (researchTimeList.value || []).map(item => item.id));
const getTargetSelection = ids => (ids === researchTypeIds.value ? selectedResearchIds : selectedResearchTimeIds);
const isGroupAllSelected = ids =>
ids.length > 0 && ids.every(id => getTargetSelection(ids).value.includes(id));
const handleAreaGroupChange = (val) => {
selectedResearchIds.value = normalizeExclusiveAllOption(val, RESOURCE_FILTER_ALL_AREA);
handleGetThinkDynamicsReport();
};
const handleToggleAll = (checked, ids) => {
if (!ids.length) return;
const targetSelection = getTargetSelection(ids);
const nextSelected = new Set(targetSelection.value);
if (checked) {
ids.forEach(id => nextSelected.add(id));
} else {
ids.forEach(id => nextSelected.delete(id));
}
targetSelection.value = [...nextSelected];
const handleTimeGroupChange = (val) => {
selectedResearchTimeIds.value = normalizeExclusiveAllOption(val, RESOURCE_FILTER_ALL_TIME);
handleGetThinkDynamicsReport();
};
......@@ -326,6 +354,10 @@ const handleToReportDetail = item => {
}
}
:deep(.keyword-highlight) {
background-color: #fff59d;
}
.right {
width: 1284px;
height: 1377px;
......
......@@ -21,16 +21,17 @@
<div class="title">{{ "科技领域" }}</div>
</div>
<div class="select-main">
<div class="checkbox-group">
<el-checkbox class="filter-checkbox" :model-value="isGroupAllSelected(researchTypeIds)"
@change="val => handleToggleAll(val, researchTypeIds)">
全部领域
<el-checkbox-group
class="checkbox-group"
:model-value="selectedResearchIds"
@change="handleAreaGroupChange">
<el-checkbox class="filter-checkbox" :label="RESOURCE_FILTER_ALL_AREA">
{{ RESOURCE_FILTER_ALL_AREA }}
</el-checkbox>
<el-checkbox v-for="type in researchTypeList" :key="type.id" v-model="selectedResearchIds" :label="type.id"
class="filter-checkbox" @change="handleGetThinkDynamicsReport()">
<el-checkbox v-for="type in researchTypeList" :key="type.id" class="filter-checkbox" :label="type.id">
{{ type.name }}
</el-checkbox>
</div>
</el-checkbox-group>
</div>
</div>
<div class="select-time-box">
......@@ -39,16 +40,17 @@
<div class="title">{{ "发布时间" }}</div>
</div>
<div class="select-main">
<div class="checkbox-group">
<el-checkbox class="filter-checkbox" :model-value="isGroupAllSelected(researchTimeIds)"
@change="val => handleToggleAll(val, researchTimeIds)">
全部时间
<el-checkbox-group
class="checkbox-group"
:model-value="selectedResearchTimeIds"
@change="handleTimeGroupChange">
<el-checkbox class="filter-checkbox" :label="RESOURCE_FILTER_ALL_TIME">
{{ RESOURCE_FILTER_ALL_TIME }}
</el-checkbox>
<el-checkbox v-for="type in researchTimeList" :key="type.id" v-model="selectedResearchTimeIds"
:label="type.id" class="filter-checkbox" @change="handleGetThinkDynamicsReport()">
<el-checkbox v-for="type in researchTimeList" :key="type.id" class="filter-checkbox" :label="type.id">
{{ type.name }}
</el-checkbox>
</div>
</el-checkbox-group>
</div>
<!-- <div class="input-main">
<el-input placeholder="输入作者名字" v-model="author" @input="handleGetThinkDynamicsReport()" />
......@@ -63,7 +65,7 @@
<img :src="item.imageUrl" alt="" />
</div>
<div class="footer-card-title">
{{ item.name }}
<span v-html="highlightText(item.name)"></span>
</div>
<div class="footer-card-footer">
<div class="time">{{ item.times }}</div>
......@@ -84,7 +86,14 @@
</div>
</template>
<script setup>
import { computed, ref, toRefs, watch } from "vue";
import { ref, toRefs, watch } from "vue";
import {
RESOURCE_FILTER_ALL_AREA,
RESOURCE_FILTER_ALL_TIME,
normalizeExclusiveAllOption,
stripAllAreaForRequest,
stripAllTimeForRequest
} from "../../../utils/resourceLibraryFilters";
const props = defineProps({
researchTypeList: {
......@@ -114,6 +123,10 @@ const props = defineProps({
currentPage: {
type: Number,
default: 1
},
searchKeyword: {
type: String,
default: ""
}
});
......@@ -127,43 +140,59 @@ const emit = defineEmits([
// 解构 props,保持模板里变量名不变
const { researchTypeList, researchTimeList, curFooterList, total, currentPage } = toRefs(props);
const selectedResearchIds = ref([]);
const selectedResearchTimeIds = ref([]);
const selectedResearchIds = ref([RESOURCE_FILTER_ALL_AREA]);
const selectedResearchTimeIds = ref([RESOURCE_FILTER_ALL_TIME]);
const escapeHtml = (text) => {
return String(text ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
};
const escapeRegExp = (text) => {
return String(text ?? "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
};
const highlightText = (text) => {
const safeText = escapeHtml(text);
const keyword = (props.searchKeyword || "").trim();
if (!keyword) return safeText;
const pattern = new RegExp(`(${escapeRegExp(keyword)})`, "gi");
return safeText.replace(pattern, '<span class="keyword-highlight">$1</span>');
};
// 父组件更新时同步到子组件
// 父组件更新时同步到子组件(接口层无选中时为「全部」)
watch(
() => props.selectedFilters,
val => {
selectedResearchIds.value = val?.researchTypeIds ? [...val.researchTypeIds] : [];
selectedResearchTimeIds.value = val?.researchTimeIds ? [...val.researchTimeIds] : [];
selectedResearchIds.value =
val?.researchTypeIds?.length > 0
? [...val.researchTypeIds]
: [RESOURCE_FILTER_ALL_AREA];
selectedResearchTimeIds.value =
val?.researchTimeIds?.length > 0
? [...val.researchTimeIds]
: [RESOURCE_FILTER_ALL_TIME];
},
{ immediate: true, deep: true }
);
const buildSelectedFiltersPayload = () => ({
researchTypeIds: [...selectedResearchIds.value],
researchTimeIds: [...selectedResearchTimeIds.value],
researchTypeIds: stripAllAreaForRequest(selectedResearchIds.value),
researchTimeIds: stripAllTimeForRequest(selectedResearchTimeIds.value),
researchHearingIds: []
});
const researchTypeIds = computed(() => (researchTypeList.value || []).map(item => item.id));
const researchTimeIds = computed(() => (researchTimeList.value || []).map(item => item.id));
const getTargetSelection = ids => (ids === researchTypeIds.value ? selectedResearchIds : selectedResearchTimeIds);
const isGroupAllSelected = ids =>
ids.length > 0 && ids.every(id => getTargetSelection(ids).value.includes(id));
const handleAreaGroupChange = (val) => {
selectedResearchIds.value = normalizeExclusiveAllOption(val, RESOURCE_FILTER_ALL_AREA);
handleGetThinkDynamicsReport();
};
const handleToggleAll = (checked, ids) => {
if (!ids.length) return;
const targetSelection = getTargetSelection(ids);
const nextSelected = new Set(targetSelection.value);
if (checked) {
ids.forEach(id => nextSelected.add(id));
} else {
ids.forEach(id => nextSelected.delete(id));
}
targetSelection.value = [...nextSelected];
const handleTimeGroupChange = (val) => {
selectedResearchTimeIds.value = normalizeExclusiveAllOption(val, RESOURCE_FILTER_ALL_TIME);
handleGetThinkDynamicsReport();
};
......@@ -326,6 +355,10 @@ const handleToReportDetail = item => {
}
}
:deep(.keyword-highlight) {
background-color: #fff59d;
}
.right {
width: 1284px;
height: 1377px;
......
......@@ -51,7 +51,7 @@
}">
<template #prefix>
<img src="../assets/images/sort-asc.png" class="select-prefix-img" alt="" @click.stop="toggleSort()"
:key="true" label="正序" :value="true" v-if="sort === true" />
:key="true" label="正序" :value="true" v-if="sort !== false" />
<img src="../assets/images/sort-desc.png" class="select-prefix-img" alt="" @click.stop="toggleSort()"
:key="false" label="倒序" :value="false" v-if="sort === false" />
</template>
......@@ -191,7 +191,7 @@ const handleGetThinkTankList = async () => {
total.value = 0;
}
};
// 初始为 null:el-select 显示 placeholder;但排序仍按“正序”规则(见 sortedCardList)
// 默认显示 placeholder「报告数量」,但前缀图标显示正序
const sort = ref(null);
const toggleSort = () => {
sort.value = sort.value === false ? true : false
......
......@@ -7,15 +7,21 @@
<div class="title">{{ "科技领域" }}</div>
</div>
<div class="select-main">
<div class="checkbox-group">
<el-checkbox v-model="checkAllModel" class="all-checkbox" @change="emit('check-all-change', $event)">
全部领域
<el-checkbox-group
class="checkbox-group"
:model-value="selectedAreaList"
@change="handleAreaGroupChange">
<el-checkbox class="filter-checkbox all-checkbox" :label="RESOURCE_FILTER_ALL_AREA">
{{ RESOURCE_FILTER_ALL_AREA }}
</el-checkbox>
<el-checkbox v-for="research in areaList" :key="research.id" v-model="selectedAreaListModel"
:label="research.id" @change="emit('checked-area-change')" class="filter-checkbox">
<el-checkbox
v-for="research in areaList"
:key="research.id"
class="filter-checkbox"
:label="research.id">
{{ research.name }}
</el-checkbox>
</div>
</el-checkbox-group>
</div>
</div>
......@@ -25,17 +31,21 @@
<div class="title">{{ "发布时间" }}</div>
</div>
<div class="select-main">
<div class="checkbox-group">
<el-checkbox v-model="checkAllTimeModel" class="all-checkbox"
@change="emit('check-all-time-change', $event)">
全部时间
<el-checkbox-group
class="checkbox-group"
:model-value="selectedPubTimeList"
@change="handleTimeGroupChange">
<el-checkbox class="filter-checkbox all-checkbox" :label="RESOURCE_FILTER_ALL_TIME">
{{ RESOURCE_FILTER_ALL_TIME }}
</el-checkbox>
<el-checkbox v-for="time in pubTimeList" :key="time.id" v-model="selectedPubTimeListModel" :label="time.id"
class="filter-checkbox" @change="emit('checked-area-time-change')">
<el-checkbox
v-for="time in pubTimeList"
:key="time.id"
class="filter-checkbox"
:label="time.id">
{{ time.name }}
</el-checkbox>
</div>
</el-checkbox-group>
</div>
</div>
</div>
......@@ -68,56 +78,39 @@
</template>
<script setup>
import { computed } from "vue";
import {
RESOURCE_FILTER_ALL_AREA,
RESOURCE_FILTER_ALL_TIME,
normalizeExclusiveAllOption
} from "../utils/resourceLibraryFilters";
const props = defineProps({
checkAll: { type: Boolean, default: false },
isIndeterminate: { type: Boolean, default: false },
defineProps({
areaList: { type: Array, default: () => [] },
selectedAreaList: { type: Array, default: () => [] },
checkAllTime: { type: Boolean, default: false },
isIndeterminateTime: { type: Boolean, default: false },
pubTimeList: { type: Array, default: () => [] },
selectedPubTimeList: { type: Array, default: () => [] },
curFooterList: { type: Array, default: () => [] },
total: { type: Number, default: 0 },
currentPage: { type: Number, default: 1 }
});
const emit = defineEmits([
"update:checkAll",
"update:selectedAreaList",
"check-all-change",
"checked-area-change",
"update:checkAllTime",
"update:selectedPubTimeList",
"check-all-time-change",
"checked-area-time-change",
"filter-change",
"report-click",
"page-change"
]);
const checkAllModel = computed({
get: () => props.checkAll,
set: val => emit("update:checkAll", val)
});
const handleAreaGroupChange = (val) => {
emit("update:selectedAreaList", normalizeExclusiveAllOption(val, RESOURCE_FILTER_ALL_AREA));
emit("filter-change");
};
const selectedAreaListModel = computed({
get: () => props.selectedAreaList,
set: val => emit("update:selectedAreaList", val)
});
const checkAllTimeModel = computed({
get: () => props.checkAllTime,
set: val => emit("update:checkAllTime", val)
});
const selectedPubTimeListModel = computed({
get: () => props.selectedPubTimeList,
set: val => emit("update:selectedPubTimeList", val)
});
const handleTimeGroupChange = (val) => {
emit("update:selectedPubTimeList", normalizeExclusiveAllOption(val, RESOURCE_FILTER_ALL_TIME));
emit("filter-change");
};
</script>
<style lang="scss" scoped>
......
<template>
<!-- 调查项目:结构/样式与智库报告一致,但组件独立,避免互相影响 -->
<!-- 调查项目:与智库报告相同的「全部」互斥逻辑 -->
<div class="home-main-footer-main">
<div class="left">
<div class="select-box">
......@@ -8,15 +8,21 @@
<div class="title">{{ "科技领域" }}</div>
</div>
<div class="select-main">
<div class="checkbox-group">
<el-checkbox v-model="checkAllModel" class="all-checkbox" @change="emit('check-all-change', $event)">
全部领域
<el-checkbox-group
class="checkbox-group"
:model-value="selectedAreaList"
@change="handleAreaGroupChange">
<el-checkbox class="filter-checkbox all-checkbox" :label="RESOURCE_FILTER_ALL_AREA">
{{ RESOURCE_FILTER_ALL_AREA }}
</el-checkbox>
<el-checkbox v-for="research in areaList" :key="research.id" v-model="selectedAreaListModel"
:label="research.id" @change="emit('checked-area-change')" class="filter-checkbox">
<el-checkbox
v-for="research in areaList"
:key="research.id"
class="filter-checkbox"
:label="research.id">
{{ research.name }}
</el-checkbox>
</div>
</el-checkbox-group>
</div>
</div>
......@@ -26,16 +32,21 @@
<div class="title">{{ "发布时间" }}</div>
</div>
<div class="select-main">
<div class="checkbox-group">
<el-checkbox v-model="checkAllTimeModel" class="all-checkbox"
@change="emit('check-all-time-change', $event)">
全部时间
<el-checkbox-group
class="checkbox-group"
:model-value="selectedPubTimeList"
@change="handleTimeGroupChange">
<el-checkbox class="filter-checkbox all-checkbox" :label="RESOURCE_FILTER_ALL_TIME">
{{ RESOURCE_FILTER_ALL_TIME }}
</el-checkbox>
<el-checkbox v-model="selectedPubTimeListModel" v-for="time in pubTimeList" :key="time.id" :label="time.id"
class="filter-checkbox" @change="emit('checked-area-time-change')">
<el-checkbox
v-for="time in pubTimeList"
:key="time.id"
class="filter-checkbox"
:label="time.id">
{{ time.name }}
</el-checkbox>
</div>
</el-checkbox-group>
</div>
</div>
</div>
......@@ -68,56 +79,39 @@
</template>
<script setup>
import { computed } from "vue";
import {
RESOURCE_FILTER_ALL_AREA,
RESOURCE_FILTER_ALL_TIME,
normalizeExclusiveAllOption
} from "../utils/resourceLibraryFilters";
const props = defineProps({
checkAll: { type: Boolean, default: false },
isIndeterminate: { type: Boolean, default: false },
defineProps({
areaList: { type: Array, default: () => [] },
selectedAreaList: { type: Array, default: () => [] },
checkAllTime: { type: Boolean, default: false },
isIndeterminateTime: { type: Boolean, default: false },
pubTimeList: { type: Array, default: () => [] },
selectedPubTimeList: { type: Array, default: () => [] },
curFooterList: { type: Array, default: () => [] },
total: { type: Number, default: 0 },
currentPage: { type: Number, default: 1 }
});
const emit = defineEmits([
"update:checkAll",
"update:selectedAreaList",
"check-all-change",
"checked-area-change",
"update:checkAllTime",
"update:selectedPubTimeList",
"check-all-time-change",
"checked-area-time-change",
"filter-change",
"report-click",
"page-change"
]);
const checkAllModel = computed({
get: () => props.checkAll,
set: val => emit("update:checkAll", val)
});
const selectedAreaListModel = computed({
get: () => props.selectedAreaList,
set: val => emit("update:selectedAreaList", val)
});
const handleAreaGroupChange = (val) => {
emit("update:selectedAreaList", normalizeExclusiveAllOption(val, RESOURCE_FILTER_ALL_AREA));
emit("filter-change");
};
const checkAllTimeModel = computed({
get: () => props.checkAllTime,
set: val => emit("update:checkAllTime", val)
});
const selectedPubTimeListModel = computed({
get: () => props.selectedPubTimeList,
set: val => emit("update:selectedPubTimeList", val)
});
const handleTimeGroupChange = (val) => {
emit("update:selectedPubTimeList", normalizeExclusiveAllOption(val, RESOURCE_FILTER_ALL_TIME));
emit("filter-change");
};
</script>
<style lang="scss" scoped>
......
......@@ -7,16 +7,21 @@
<div class="title">{{ "科技领域" }}</div>
</div>
<div class="select-main">
<div class="checkbox-group">
<el-checkbox class="filter-checkbox" :model-value="isGroupAllSelected(researchTypeIds)"
@change="val => handleToggleAll(val, researchTypeIds)">
全部领域
<el-checkbox-group
class="checkbox-group"
:model-value="selectedResearchIds"
@change="handleAreaGroupChange">
<el-checkbox class="filter-checkbox" :label="RESOURCE_FILTER_ALL_AREA">
{{ RESOURCE_FILTER_ALL_AREA }}
</el-checkbox>
<el-checkbox v-for="type in researchTypeList" :key="type.id" v-model="selectedResearchIds" :label="type.id"
class="filter-checkbox" @change="handleFilterChange">
<el-checkbox
v-for="type in researchTypeList"
:key="type.id"
class="filter-checkbox"
:label="type.id">
{{ type.name }}
</el-checkbox>
</div>
</el-checkbox-group>
</div>
</div>
......@@ -26,16 +31,21 @@
<div class="title">{{ "发布时间" }}</div>
</div>
<div class="select-main">
<div class="checkbox-group">
<el-checkbox class="filter-checkbox" :model-value="isGroupAllSelected(researchTimeIds)"
@change="val => handleToggleAll(val, researchTimeIds)">
全部时间
<el-checkbox-group
class="checkbox-group"
:model-value="selectedResearchTimeIds"
@change="handleTimeGroupChange">
<el-checkbox class="filter-checkbox" :label="RESOURCE_FILTER_ALL_TIME">
{{ RESOURCE_FILTER_ALL_TIME }}
</el-checkbox>
<el-checkbox v-for="type in researchTimeList" :key="type.id" v-model="selectedResearchTimeIds"
:label="type.id" class="filter-checkbox" @change="handleFilterChange">
<el-checkbox
v-for="type in researchTimeList"
:key="type.id"
class="filter-checkbox"
:label="type.id">
{{ type.name }}
</el-checkbox>
</div>
</el-checkbox-group>
</div>
</div>
......@@ -45,16 +55,21 @@
<div class="title">{{ "听证会部门" }}</div>
</div>
<div class="select-main">
<div class="checkbox-group hearing-grid">
<el-checkbox class="filter-checkbox" :model-value="isGroupAllSelected(researchHearingIds)"
@change="val => handleToggleAll(val, researchHearingIds)">
全部部门
<el-checkbox-group
class="checkbox-group hearing-grid"
:model-value="selectedResearchHearingIds"
@change="handleDeptGroupChange">
<el-checkbox class="filter-checkbox" :label="RESOURCE_FILTER_ALL_DEPT">
{{ RESOURCE_FILTER_ALL_DEPT }}
</el-checkbox>
<el-checkbox v-for="type in researchHearingList" :key="type.id" v-model="selectedResearchHearingIds"
:label="type.id" class="filter-checkbox" @change="handleFilterChange">
<el-checkbox
v-for="type in researchHearingList"
:key="type.id"
class="filter-checkbox"
:label="type.id">
{{ type.name }}
</el-checkbox>
</div>
</el-checkbox-group>
</div>
</div>
</div>
......@@ -76,8 +91,7 @@
class="card-open-image" />
</div>
<div class="card-item-category">
<AreaTag :key="index" :tagName="item.category">
</AreaTag>
<AreaTag :key="`cat-${item.id}`" :tagName="item.category" />
</div>
</div>
</div>
......@@ -101,6 +115,18 @@
<script setup>
import { computed, ref } from "vue";
import AreaTag from "@/components/base/AreaTag/index.vue";
import {
RESOURCE_FILTER_ALL_AREA,
RESOURCE_FILTER_ALL_TIME,
RESOURCE_FILTER_ALL_DEPT,
RESOURCE_FILTER_EARLIER,
normalizeExclusiveAllOption,
stripAllAreaForRequest,
stripAllTimeForRequest,
stripAllDeptForRequest,
matchesEarlierChineseDate
} from "../utils/resourceLibraryFilters";
const props = defineProps({
researchTypeList: { type: Array, default: () => [] },
......@@ -112,11 +138,10 @@ const emit = defineEmits(["report-click"]);
const pageSize = 10;
const currentPage = ref(1);
const selectedResearchIds = ref([]);
const selectedResearchTimeIds = ref([]);
const selectedResearchHearingIds = ref([]);
const selectedResearchIds = ref([RESOURCE_FILTER_ALL_AREA]);
const selectedResearchTimeIds = ref([RESOURCE_FILTER_ALL_TIME]);
const selectedResearchHearingIds = ref([RESOURCE_FILTER_ALL_DEPT]);
// 概览页暂无独立接口时,先使用一份静态数据(结构与智库动态保持一致)
const hearingData = ref([
{ id: 1, title: "美国国会听证会:人工智能与国家安全", content: "美中经济与安全审查委员会", category: "人工智能", time: "2025年7月8日" },
{ id: 2, title: "美国国会听证会:先进制造供应链韧性", content: "国会-行政部门中国委员会", category: "先进制造", time: "2025年6月15日" },
......@@ -138,46 +163,44 @@ const researchHearingList = ref([
{ id: "美国商务部", name: "美国商务部" },
]);
const researchTypeIds = computed(() => (props.researchTypeList || []).map(item => item.id));
const researchTimeIds = computed(() => (props.researchTimeList || []).map(item => item.id));
const researchHearingIds = computed(() => (researchHearingList.value || []).map(item => item.id));
const handleAreaGroupChange = (val) => {
selectedResearchIds.value = normalizeExclusiveAllOption(val, RESOURCE_FILTER_ALL_AREA);
currentPage.value = 1;
};
const getTargetSelection = ids => {
if (ids === researchTypeIds.value) return selectedResearchIds;
if (ids === researchTimeIds.value) return selectedResearchTimeIds;
return selectedResearchHearingIds;
const handleTimeGroupChange = (val) => {
selectedResearchTimeIds.value = normalizeExclusiveAllOption(val, RESOURCE_FILTER_ALL_TIME);
currentPage.value = 1;
};
const isGroupAllSelected = ids =>
ids.length > 0 && ids.every(id => getTargetSelection(ids).value.includes(id));
const handleToggleAll = (checked, ids) => {
if (!ids.length) return;
const targetSelection = getTargetSelection(ids);
const nextSelected = new Set(targetSelection.value);
if (checked) {
ids.forEach(id => nextSelected.add(id));
} else {
ids.forEach(id => nextSelected.delete(id));
}
targetSelection.value = [...nextSelected];
handleFilterChange();
const handleDeptGroupChange = (val) => {
selectedResearchHearingIds.value = normalizeExclusiveAllOption(val, RESOURCE_FILTER_ALL_DEPT);
currentPage.value = 1;
};
const filteredHearingData = computed(() => {
const areaSel = stripAllAreaForRequest(selectedResearchIds.value);
const timeSel = stripAllTimeForRequest(selectedResearchTimeIds.value);
const deptSel = stripAllDeptForRequest(selectedResearchHearingIds.value);
return (hearingData.value || []).filter(item => {
const matchYear =
selectedResearchTimeIds.value.length === 0 ||
selectedResearchTimeIds.value.some(year => String(item.time || "").startsWith(year));
timeSel.length === 0 ||
timeSel.some(sel => {
if (sel === RESOURCE_FILTER_EARLIER) {
return matchesEarlierChineseDate(item.time);
}
return String(item.time || "").startsWith(String(sel));
});
const matchDepartment =
selectedResearchHearingIds.value.length === 0 ||
selectedResearchHearingIds.value.some(department =>
deptSel.length === 0 ||
deptSel.some(department =>
String(item.content || "").includes(department) || String(item.title || "").includes(department)
);
const matchType =
selectedResearchIds.value.length === 0 ||
selectedResearchIds.value.some(typeId =>
String(item.category || "").includes(typeId) || String(item.title || "").includes(typeId)
areaSel.length === 0 ||
areaSel.some(typeId =>
String(item.category || "").includes(String(typeId)) || String(item.title || "").includes(String(typeId))
);
return matchYear && matchDepartment && matchType;
});
......@@ -189,10 +212,6 @@ const displayList = computed(() => {
return list.slice(start, start + pageSize);
});
const handleFilterChange = () => {
currentPage.value = 1;
};
const handlePageChange = page => {
currentPage.value = page;
};
......
......@@ -7,16 +7,21 @@
<div class="title">{{ "科技领域" }}</div>
</div>
<div class="select-main">
<div class="checkbox-group">
<el-checkbox class="filter-checkbox" :model-value="isAllSelected(typeIds)"
@change="val => toggleAll(val, typeIds)">
全部领域
<el-checkbox-group
class="checkbox-group"
:model-value="selectedTypeIds"
@change="handleAreaGroupChange">
<el-checkbox class="filter-checkbox" :label="RESOURCE_FILTER_ALL_AREA">
{{ RESOURCE_FILTER_ALL_AREA }}
</el-checkbox>
<el-checkbox class="filter-checkbox" v-for="t in (researchTypeList || [])" :key="t.id"
v-model="selectedTypeIds" :label="t.id" @change="emitChange">
<el-checkbox
class="filter-checkbox"
v-for="t in (researchTypeList || [])"
:key="t.id"
:label="t.id">
{{ t.name }}
</el-checkbox>
</div>
</el-checkbox-group>
</div>
</div>
......@@ -26,16 +31,21 @@
<div class="title">{{ "发布时间" }}</div>
</div>
<div class="select-main">
<div class="checkbox-group">
<el-checkbox class="filter-checkbox" :model-value="isAllSelected(yearIds)"
@change="val => toggleAll(val, yearIds)">
全部时间
<el-checkbox-group
class="checkbox-group"
:model-value="selectedYearIds"
@change="handleYearGroupChange">
<el-checkbox class="filter-checkbox" :label="RESOURCE_FILTER_ALL_TIME">
{{ RESOURCE_FILTER_ALL_TIME }}
</el-checkbox>
<el-checkbox class="filter-checkbox" v-for="y in (researchTimeList || [])" :key="y.id"
v-model="selectedYearIds" :label="y.id" @change="emitChange">
<el-checkbox
class="filter-checkbox"
v-for="y in (researchTimeList || [])"
:key="y.id"
:label="y.id">
{{ y.name }}
</el-checkbox>
</div>
</el-checkbox-group>
</div>
</div>
</div>
......@@ -95,10 +105,17 @@
</template>
<script setup>
import { computed, ref } from "vue";
import { ref } from "vue";
import AreaTag from "@/components/base/AreaTag/index.vue";
const props = defineProps({
import {
RESOURCE_FILTER_ALL_AREA,
RESOURCE_FILTER_ALL_TIME,
normalizeExclusiveAllOption,
stripAllAreaForRequest,
stripAllTimeForRequest
} from "../utils/resourceLibraryFilters";
defineProps({
researchTypeList: { type: Array, default: () => [] },
researchTimeList: { type: Array, default: () => [] },
list: { type: Array, default: () => [] },
......@@ -109,29 +126,25 @@ const props = defineProps({
const emit = defineEmits(["filter-change", "page-change", "item-click"]);
const selectedTypeIds = ref([]);
const selectedYearIds = ref([]);
const selectedTypeIds = ref([RESOURCE_FILTER_ALL_AREA]);
const selectedYearIds = ref([RESOURCE_FILTER_ALL_TIME]);
const typeIds = computed(() => (props.researchTypeList || []).map(i => i.id));
const yearIds = computed(() => (props.researchTimeList || []).map(i => i.id));
const isAllSelected = ids => ids.length > 0 && ids.every(id => {
const target = ids === typeIds.value ? selectedTypeIds.value : selectedYearIds.value
return target.includes(id)
});
const toggleAll = (checked, ids) => {
const target = ids === typeIds.value ? selectedTypeIds : selectedYearIds
target.value = checked ? [...ids] : []
emitChange()
}
const emitChange = () => {
const emitFilterToParent = () => {
emit("filter-change", {
researchTypeIds: [...selectedTypeIds.value],
researchTimeIds: [...selectedYearIds.value],
})
}
researchTypeIds: stripAllAreaForRequest(selectedTypeIds.value),
researchTimeIds: stripAllTimeForRequest(selectedYearIds.value),
});
};
const handleAreaGroupChange = (val) => {
selectedTypeIds.value = normalizeExclusiveAllOption(val, RESOURCE_FILTER_ALL_AREA);
emitFilterToParent();
};
const handleYearGroupChange = (val) => {
selectedYearIds.value = normalizeExclusiveAllOption(val, RESOURCE_FILTER_ALL_TIME);
emitFilterToParent();
};
</script>
<style lang="scss" scoped>
......
import * as echarts from 'echarts';
/** 图例分页:每页展示的图例项数量(box5 数量变化趋势) */
export const MULTILINE_LEGEND_SHOW_COUNT = 11;
/**
* @param {{ title: unknown[], data: Array<{ name: string, value: unknown[], color?: string }> }} data
* @param {{ legendShowCount?: number, legendPageIndex?: number }} [options]
*/
const getMultiLineChart = (data, options = {}) => {
const getMultiLineChart = (data) => {
// 提取标题和系列数据
const title = data.title
const series = data.data
const legendShowCount =
typeof options.legendShowCount === 'number' && options.legendShowCount > 0
? options.legendShowCount
: MULTILINE_LEGEND_SHOW_COUNT
const rawPageIndex = Number(options.legendPageIndex) || 0
const allNames = series.map((item) => item.name)
const pageCount = Math.max(1, Math.ceil(allNames.length / legendShowCount))
const legendPageIndex = Math.min(Math.max(0, rawPageIndex), pageCount - 1)
const legendStart = legendPageIndex * legendShowCount
const legendData = allNames.slice(legendStart, legendStart + legendShowCount)
const legendSplitAt = Math.ceil(allNames.length / 2)
const legendFirstLine = allNames.slice(0, legendSplitAt)
const legendSecondLine = allNames.slice(legendSplitAt)
// 定义配色数组
const colorList = [
......@@ -101,28 +91,46 @@ const getMultiLineChart = (data, options = {}) => {
},
/* 顶部预留足够空间:多行图例 + Y 轴标题「数量」在纵轴顶端,避免与图例重合 */
grid: {
top: '28%',
right: '5%',
top: '34%',
right: '3%',
bottom: '5%',
left: '5%',
left: '2%',
containLabel: true
},
legend: {
show: true,
type: 'plain',
data: legendData,
top: 8,
left: 'center',
icon: 'circle',
textStyle: {
fontFamily: 'Source Han Sans CN', // 字体
fontWeight: 400, // 字重值(Regular对应400)
fontSize: 14, // 字号
lineHeight: 24, // 行高
letterSpacing: 0, // 字间距
align: 'left' // 文本左对齐
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'
}
}
},
],
color: colorList, // 使用预设的配色数组
xAxis: [
{
......
/** 与政策追踪侧筛选文案保持一致 */
export const RESOURCE_FILTER_ALL_AREA = "全部领域";
export const RESOURCE_FILTER_ALL_TIME = "全部时间";
export const RESOURCE_FILTER_ALL_DEPT = "全部部门";
/**
* 「全部」与具体项互斥;空选时回到「全部」
* (同市场准入/政策追踪 resourceLibrary normalizeWithAll)
*/
export function normalizeExclusiveAllOption(val, allLabel) {
if (!Array.isArray(val)) {
return [allLabel];
}
if (val.includes(allLabel) && val.length > 1) {
if (val[val.length - 1] === allLabel) {
return [allLabel];
}
return val.filter((item) => item !== allLabel);
}
if (val.length === 0) {
return [allLabel];
}
return val;
}
/** 发请求前去掉「全部领域」占位 */
export function stripAllAreaForRequest(arr) {
return (Array.isArray(arr) ? arr : []).filter((id) => id !== RESOURCE_FILTER_ALL_AREA);
}
/** 发请求前去掉「全部时间」占位 */
export function stripAllTimeForRequest(arr) {
return (Array.isArray(arr) ? arr : []).filter(
(id) => id !== RESOURCE_FILTER_ALL_TIME && id !== "" && id != null
);
}
/** 发请求前去掉「全部部门」占位 */
export function stripAllDeptForRequest(arr) {
return (Array.isArray(arr) ? arr : []).filter((id) => id !== RESOURCE_FILTER_ALL_DEPT);
}
/**
* 已选中的具体项是否覆盖选项全集(与仅选「全部领域/全部时间」等价:请求层不按子项狭义过滤)
* @param {unknown[]} selectedStrippedIds 已去掉「全部」占位后的选中 id
* @param {unknown[]} allOptionIds 当前列表全部可选项 id(与勾选框 label 一致)
*/
export function isSelectionCoveringAllOptions(selectedStrippedIds, allOptionIds) {
if (!Array.isArray(selectedStrippedIds) || !Array.isArray(allOptionIds) || allOptionIds.length === 0) {
return false;
}
if (selectedStrippedIds.length !== allOptionIds.length) {
return false;
}
const setSel = new Set(selectedStrippedIds);
return allOptionIds.every((id) => setSel.has(id));
}
/** 与政策追踪一致:勾选「更早」表示 2000~2020 自然年 */
export const RESOURCE_FILTER_EARLIER = "更早";
export const RESOURCE_YEAR_EARLIER_START = 2000;
export const RESOURCE_YEAR_EARLIER_END = 2020;
/** 展开「更早」为逐年数字(概览 report 接口 years 多为数字年) */
export function expandEarlierNumericYears() {
const out = [];
for (let y = RESOURCE_YEAR_EARLIER_START; y <= RESOURCE_YEAR_EARLIER_END; y += 1) {
out.push(y);
}
return out;
}
/**
* 概览资源库:去掉「全部时间」后的选中项 → years 查询串(含「更早」则展开为 2000…2020)
*/
export function buildNumericYearsQueryString(strippedTimeIds) {
const list = Array.isArray(strippedTimeIds) ? strippedTimeIds : [];
const yearsSet = new Set();
for (const id of list) {
if (id === RESOURCE_FILTER_EARLIER) {
expandEarlierNumericYears().forEach((y) => yearsSet.add(y));
} else if (id !== null && id !== undefined && id !== "") {
const n = Number(id);
if (!Number.isNaN(n)) {
yearsSet.add(n);
}
}
}
return [...yearsSet].sort((a, b) => b - a).join(",");
}
const DYNAMICS_YEAR_LABEL_RE = /^(\d{4})年$/;
/**
* 智库动态:时间为「2025年」等字符串 id → years 查询串(「更早」展开为 2000…2020)
*/
export function buildDynamicsYearsQueryString(strippedTimeIds) {
const list = Array.isArray(strippedTimeIds) ? strippedTimeIds : [];
const yearsSet = new Set();
for (const id of list) {
if (id === RESOURCE_FILTER_EARLIER) {
expandEarlierNumericYears().forEach((y) => yearsSet.add(y));
continue;
}
const m = String(id).match(DYNAMICS_YEAR_LABEL_RE);
if (m) {
yearsSet.add(Number(m[1]));
}
}
return [...yearsSet].sort((a, b) => b - a).join(",");
}
/**
* 中文日期串如「2020年5月1日」是否落在「更早」区间(与政策追踪矩形树一致)
*/
export function matchesEarlierChineseDate(timeStr) {
const m = String(timeStr || "").match(/^(\d{4})年/);
if (!m) {
return false;
}
const y = Number(m[1]);
return y >= RESOURCE_YEAR_EARLIER_START && y <= RESOURCE_YEAR_EARLIER_END;
}
/** 与政策追踪「仅全部时间」一致:固定起止(结束日按产品要求) */
export const RESOURCE_REPORT_ALL_TIME_START = "2000-01-01";
export const RESOURCE_REPORT_ALL_TIME_END = "2025-12-31";
function getResourceReportDateYearsAgo(years) {
const currentDate = new Date();
const pastDate = new Date(currentDate.getFullYear() - years, currentDate.getMonth(), currentDate.getDate());
const y = pastDate.getFullYear();
const month = String(pastDate.getMonth() + 1).padStart(2, "0");
const day = String(pastDate.getDate()).padStart(2, "0");
return `${y}-${month}-${day}`;
}
function getResourceReportTodayYmd() {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
/**
* 资源库 /thinkTankOverview/report:由发布时间多选(数字年 +「更早」)推导 startDate/endDate,语义对齐政策追踪 getPolicyListDateRangeFromYearList。
* - 仅「全部时间」或选满全部具体年份 → 2000-01-01 ~ 2025-12-31
* - 单选/多选自然年 +「更早」→ 取最小年 01-01 与最大年 12-31 包络(「更早」为 2000~2020)
* - 无法解析时的兜底:近 relativeYearsAgo 年至今天
*
* @param {unknown[]} strippedTimeIds stripAllTimeForRequest 后的选中项
* @param {unknown[]} allTimeOptionIds 当前 pubTimeList 全部 id(用于判断是否选满 = 全部时间)
* @param {number} [relativeYearsAgo=1] 兜底用「近 N 年」
* @returns {{ startDate: string, endDate: string }}
*/
export function getResourceLibraryReportDateRangeFromTimeSelection(
strippedTimeIds,
allTimeOptionIds,
relativeYearsAgo = 1
) {
const stripped = Array.isArray(strippedTimeIds) ? strippedTimeIds : [];
const allIds = Array.isArray(allTimeOptionIds) ? allTimeOptionIds : [];
const isAllTime =
stripped.length === 0 ||
(allIds.length > 0 && isSelectionCoveringAllOptions(stripped, allIds));
if (isAllTime) {
return {
startDate: RESOURCE_REPORT_ALL_TIME_START,
endDate: RESOURCE_REPORT_ALL_TIME_END,
};
}
let minY = null;
let maxY = null;
const set = new Set(stripped);
if (set.has(RESOURCE_FILTER_EARLIER)) {
minY = RESOURCE_YEAR_EARLIER_START;
maxY = RESOURCE_YEAR_EARLIER_END;
}
const yearLabelCnRe = /^(\d{4})年$/;
for (const id of set) {
if (id === RESOURCE_FILTER_EARLIER) {
continue;
}
let yNum = Number(id);
if (Number.isNaN(yNum)) {
const m = String(id).match(yearLabelCnRe);
if (m) {
yNum = Number(m[1]);
}
}
if (!Number.isNaN(yNum)) {
minY = minY == null ? yNum : Math.min(minY, yNum);
maxY = maxY == null ? yNum : Math.max(maxY, yNum);
}
}
if (minY == null || maxY == null) {
const y = Number(relativeYearsAgo) > 0 ? Number(relativeYearsAgo) : 1;
return {
startDate: getResourceReportDateYearsAgo(y),
endDate: getResourceReportTodayYmd(),
};
}
return {
startDate: `${minY}-01-01`,
endDate: `${maxY}-12-31`,
};
}
......@@ -6,7 +6,7 @@ const getSankeyChart = (nodes, links) => {
type: 'sankey',
layout: 'none',
left: '2%',
right: '18%',
right: '15%',
top: '5%',
bottom: '5%',
emphasis: {
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论