提交 1c13c291 authored 作者: coderBryanFu's avatar coderBryanFu

法案概览页新增图表跳转资源库

......@@ -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();
......@@ -119,9 +166,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,则提前解析并中断连接
......@@ -129,6 +185,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 (_) { }
......@@ -137,8 +197,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||
......
// 智库概览信息
import request from "@/api/request.js";
import request, { getToken } from "@/api/request.js";
// 智库列表
export function getThinkTankList() {
......@@ -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',
......@@ -158,6 +162,7 @@ export function getThinkDynamicsReport(params) {
// 智库领域观点分析(流式)
// [POST] 8.140.26.4:10029/report-domain-view-analysis
// 每次请求体:{ domain, report_view_list }(一个 domain);多领域由前端按领域循环多次调用
export function postReportDomainViewAnalysis(data) {
return request({
method: 'POST',
......@@ -167,6 +172,86 @@ export function postReportDomainViewAnalysis(data) {
})
}
/**
* 智库领域观点分析(真正流式,逐 chunk 回调)
* @param {object} data
* @param {{ onReasoningChunk?: (chunk: string) => void, onMessage?: (msg: any) => void }} handlers
*/
export async function postReportDomainViewAnalysisStream(data, handlers = {}) {
const { onReasoningChunk, onMessage } = handlers
const token = getToken()
const response = await fetch('/intelligent-api/report-domain-view-analysis', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { token } : {})
},
body: JSON.stringify(data)
})
if (!response.ok) {
throw new Error(`流式分析请求失败: ${response.status}`)
}
// 兜底:非流式返回时仍可读取文本继续后续解析
if (!response.body) {
return await response.text()
}
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let done = false
let pending = ''
let fullText = ''
while (!done) {
const result = await reader.read()
done = result.done
if (result.value) {
const chunkText = decoder.decode(result.value, { stream: !done })
fullText += chunkText
pending += chunkText
const lines = pending.split(/\r?\n/)
pending = lines.pop() ?? ''
for (const rawLine of lines) {
const line = String(rawLine || '').trim()
if (!line || !line.startsWith('data:')) continue
const jsonText = line.slice(5).trim()
if (!jsonText || jsonText === '[DONE]') continue
try {
const msg = JSON.parse(jsonText)
if (typeof onMessage === 'function') onMessage(msg)
if (msg?.type === 'reasoning' && msg?.chunk != null && typeof onReasoningChunk === 'function') {
const c = String(msg.chunk)
if (c) onReasoningChunk(c)
}
} catch (e) {
// 忽略非 JSON 数据行
}
}
}
}
// 处理最后一行残留
const last = String(pending || '').trim()
if (last.startsWith('data:')) {
const jsonText = last.slice(5).trim()
if (jsonText && jsonText !== '[DONE]') {
try {
const msg = JSON.parse(jsonText)
if (typeof onMessage === 'function') onMessage(msg)
if (msg?.type === 'reasoning' && msg?.chunk != null && typeof onReasoningChunk === 'function') {
const c = String(msg.chunk)
if (c) onReasoningChunk(c)
}
} catch (e) {
// ignore
}
}
}
return fullText
}
//提出建议领域分布
export function getThinkPolicyIndustry(params) {
......@@ -240,18 +325,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 +344,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,8 @@
</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 +94,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 +106,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 +126,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 +135,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 +147,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>
......@@ -158,30 +160,62 @@
</AnalysisBox>
</div>
<div class="box2">
<AnalysisBox title="共识观点列表" :showAllBtn="true" v-if="isBox2">
<AnalysisBox title="共识观点列表" :showAllBtn="true" v-if="isBox2 || isAnalysisLoading">
<div class="box2-main">
<div class="empty-image">
<div class="empty-image" v-if="isBox2 && !isAnalysisLoading">
<img src="../assets/images/empty-image.png" alt="" />
</div>
<div v-if="isAnalysisLoading && !isBeingAnalysisExpanded" class="being-analysis-box">
<div class="being-analysis-box-image">
<img src="../assets/images/being-analysis-circle.png" alt="" />
</div>
<div class="being-analysis-box-text">{{ "正在思考" }}</div>
<div class="being-analysis-box-change" @click="toggleBeingAnalysisBox">
<img src="../assets/images/blue-down.png" alt="" v-if="!isBeingAnalysisExpanded" />
<img src="../assets/images/blue-up.png" alt="" v-else />
</div>
</div>
<div v-if="isAnalysisLoading && isBeingAnalysisExpanded" class="being-analysis-detail-box">
<div class="being-analysis-box-header">
<div class="being-analysis-box-image">
<img src="../assets/images/being-analysis-circle.png" alt="" />
</div>
<div class="being-analysis-box-text">{{ "正在思考" }}</div>
<div class="being-analysis-box-change" @click="toggleBeingAnalysisBox">
<img src="../assets/images/blue-down.png" alt="" v-if="!isBeingAnalysisExpanded" />
<img src="../assets/images/blue-up.png" alt="" v-else />
</div>
</div>
<div class="being-analysis-box-content" ref="beingAnalysisContentRef">
<div class="being-analysis-content-text">{{ beingAnalysisContent }}</div>
</div>
</div>
<div v-if="isAnalysisLoading" class="being-analysis-image">
<img src="../assets/images/being-analysis.png" alt="" />
</div>
</div>
</AnalysisBox>
<AnalysisResultBox title="核心观点分析" :showAllBtn="false" v-if="!isBox2" @tab-change="handleOpinionTabChange">
<div class="box2-main-Consensus" v-if="activeOpinionTab === 'consensus'">
<div class="box2-main-Consensus-header">
<div class="tech-development-view">{{ "科技发展观点 1条" }}</div>
<div class="criticism-view">{{ "对我打压观点 1条" }}</div>
<AnalysisResultBox title="核心观点分析" :showAllBtn="false" v-if="!isBox2 && !isAnalysisLoading"
@tab-change="handleOpinionTabChange">
<div class="box2-main-Consensus" v-if="!isAnalysisLoading && activeOpinionTab === 'consensus'">
<!-- <div class="box2-main-Consensus-header">
<div class="tech-development-view">{{ `共识观点 ${consensusList.length}条` }}</div>
<div class="criticism-view">{{ `分歧观点 ${differenceList.length}条` }}</div>
<div class="thinkTank-view">
<div class="thinkTank-image">
<img src="../assets/images/rand-image.png" alt="" />
</div>
{{ "兰德公司 2条" }}
{{ `${domainName} ${consensusList.length + differenceList.length}条` }}
</div>
</div>
</div> -->
<div class="box2-main-Consensus-content">
<template v-for="(item, index) in mockConsensusList" :key="item.id">
<template v-for="(item, index) in consensusList" :key="item.id">
<div class="Consensus-item">
<div class="Consensus-item-number">{{ index + 1 }}</div>
<div class="Consensus-item-title">{{ item.title }}</div>
<div class="Consensus-item-title">{{ item.consensusContent }}</div>
<div class="Consensus-item-right">{{ `${item.reportCount}篇报告提及` }}</div>
<div class="Consensus-item-image" @click="toggleConsensusItem(item.id)">
<img src="../assets/images/down.png" alt="" v-if="!openConsensusIds.has(item.id)" />
......@@ -190,39 +224,37 @@
</div>
<div class="Consensus-expand" v-if="openConsensusIds.has(item.id)">
<div class="Consensus-expand-row">
<div class="Consensus-expand-title">
{{ item.childrenTitle }}
</div>
<div class="Consensus-expand-subtitle">
<div class="Consensus-expand-image">
<img src="../assets/images/rand-image.png" alt="" />
</div>
{{ item.thinkTankName }}
<div v-for="(sv, svIdx) in item.sourceViewDetails" :key="`${sv.report_id}-${sv.view_id}-${svIdx}`"
class="source-view-detail">
<div class="source-view-detail-title">
<span class="source-view-detail-title-text">{{ getSourceViewDisplayTitle(sv, svIdx) }}</span>
<span class="source-view-detail-org" v-if="sv.thinktankName || sv.thinktankLogoUrl">
<img v-if="sv.thinktankLogoUrl" :src="sv.thinktankLogoUrl" alt="" />
<span class="source-view-detail-org-text">{{ sv.thinktankName }}</span>
</span>
</div>
<div class="source-view-detail-content">{{ getSourceViewDisplayContent(sv) }}</div>
</div>
<div class="Consensus-expand-content">
{{ item.childrenContent }}
</div>
<div class="Consensus-expand-badge"
:class="{ 'is-tech-development': item.view === '科技发展观点', 'is-criticism': item.view === '对我打压观点' }">
{{ item.view }}
</div>
<div class="Consensus-expand-badge is-tech-development">{{ "共识观点" }}</div>
</div>
</template>
<div class="empty-image" v-if="consensusList.length === 0">
<img src="../assets/images/empty-analysis.png" alt="" />
</div>
<div class="empty-text" v-if="consensusList.length === 0">
{{ "未识别到共识观点..." }}
</div>
</div>
</div>
<div class="box2-main-Differences" v-else>
<div class="box2-main-Differences" v-else-if="!isAnalysisLoading">
<div class="box2-main-Differences-content">
<template v-for="(item, index) in mockDifferenceList" :key="item.id">
<template v-for="(item, index) in differenceList" :key="item.id">
<div class="Differences-item">
<div class="Differences-item-number">{{ index + 1 }}</div>
<div class="Differences-item-title">
<div class="Differences-item-title-left"> {{ item.divergent_views[0].view }}</div>
<div class="Differences-item-title-right">{{ item.divergent_views[1].view }}</div>
<div class="Differences-item-title-left"> {{ item.disagreementContent }}</div>
<div class="Differences-item-title-right">{{ `${item.reportCount}篇报告提及` }}</div>
</div>
......@@ -236,43 +268,39 @@
<div class="Differences-expand-content">
<div class="Differences-expand-top-content">
<div class="Differences-expand-top-content-left">
<div class="content-left-title">{{ item.divergent_views[0].Consensus.title }}</div>
<div class="content-left-img-name">
<div class="content-left-img">
<img src="../assets/images/rand-image.png" alt="" />
</div>
<div class="content-left-name">
{{ item.divergent_views[0].Consensus.institution }}
</div>
</div>
<div class="content-left-text">{{ item.divergent_views[0].Consensus.quote }}</div>
</div>
<div class="Differences-expand-top-content-right">
<div class="content-right-title">{{ item.divergent_views[1].Differences.title }}</div>
<div class="content-right-img-name">
<div class="content-right-img">
<img src="../assets/images/rand-image.png" alt="" />
</div>
<div class="content-right-name">
{{ item.divergent_views[1].Differences.institution }}
<div class="differences-source-views">
<div v-for="(sv, svIdx) in item.sourceViewDetails"
:key="`${sv.report_id}-${sv.view_id}-${svIdx}`" class="source-view-detail">
<div class="source-view-detail-title">
<span class="source-view-detail-title-text">{{ getSourceViewDisplayTitle(sv, svIdx)
}}</span>
<span class="source-view-detail-org" v-if="sv.thinktankName || sv.thinktankLogoUrl">
<img v-if="sv.thinktankLogoUrl" :src="sv.thinktankLogoUrl" alt="" />
<span class="source-view-detail-org-text">{{ sv.thinktankName }}</span>
</span>
</div>
<div class="source-view-detail-content">{{ getSourceViewDisplayContent(sv) }}</div>
</div>
</div>
<div class="content-right-text">{{ item.divergent_views[1].Differences.quote }}</div>
</div>
</div>
<div class="Differences-expand-view">
<div class="Differences-expand-view-left">
<div class="left-tag">{{ item.divergent_views[0].Consensus.tag }}</div>
</div>
<div class="Differences-expand-view-right">
<div class="right-tag">{{ item.divergent_views[1].Differences.tag }}</div>
<div class="left-tag">{{ "分歧观点" }}</div>
</div>
</div>
</div>
</div>
</template>
<div class="empty-image" v-if="differenceList.length === 0">
<img src="../assets/images/empty-analysis.png" alt="" />
</div>
<div class="empty-text" v-if="differenceList.length === 0">
{{ "未识别到分歧观点..." }}
</div>
</div>
</div>
</AnalysisResultBox>
......@@ -282,14 +310,40 @@
</template>
<script setup>
import router from '@/router';
import { onMounted, ref, computed, reactive, nextTick } from "vue";
import { onMounted, ref, computed, reactive, nextTick, watch } 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,
getThinkTankReportViewpoint,
postReportDomainViewAnalysisStream
} from "@/api/thinkTank/overview";
const sort = ref("");
const searchPolicy = ref("");
const isBox2 = ref(true)
const isAnalysisLoading = ref(false)
const isBeingAnalysisExpanded = ref(false)
const beingAnalysisContent = ref("")
const beingAnalysisContentRef = ref(null)
const activeOpinionTab = ref('consensus')
const toggleBeingAnalysisBox = () => {
isBeingAnalysisExpanded.value = !isBeingAnalysisExpanded.value
}
const scrollBeingAnalysisToBottom = async () => {
await nextTick()
const el = beingAnalysisContentRef.value
if (!el) return
el.scrollTop = el.scrollHeight
}
watch(() => beingAnalysisContent.value, async () => {
if (!isBeingAnalysisExpanded.value) return
await scrollBeingAnalysisToBottom()
})
watch(isBeingAnalysisExpanded, async (expanded) => {
if (!expanded) return
await scrollBeingAnalysisToBottom()
})
const handleOpinionTabChange = type => {
activeOpinionTab.value = type
}
......@@ -368,6 +422,134 @@ const domainName = computed(() => {
return hit?.name || '全部领域'
})
const domainViewAnalysisRes = ref(null)
/** 观点详情:key 为 `${reportId}::${viewId}`,与流式结果里 source_views 的 view_id 对应观点接口返回的 id */
const viewpointDetailByKey = ref({})
const getViewpointDetailForSource = (reportId, viewId) => {
const key = `${String(reportId)}::${String(viewId)}`
return viewpointDetailByKey.value[key] || {
titleZh: "",
contentZh: "",
title: "",
content: "",
thinktankName: "",
thinktankLogoUrl: ""
}
}
/** 展开区标题:优先中文标题,否则英文;无标题则返回空串(由上游过滤) */
const getSourceViewDisplayTitle = (sv, idx) => {
const zh = String(sv.titleZh ?? "").trim()
if (zh) return zh
const en = String(sv.title ?? "").trim()
if (en) return en
return ""
}
/** 展开区正文:优先中文,否则英文 */
const getSourceViewDisplayContent = (sv) => {
const zh = String(sv.contentZh ?? "").trim()
if (zh) return zh
return String(sv.content ?? "").trim()
}
const tryParseAnswerFromStreamText = (text) => {
const lines = String(text || "")
.split(/\r?\n/)
.map((x) => x.trim())
.filter(Boolean)
let lastAnswerData = null
for (const line of lines) {
if (!line.startsWith("data:")) continue
const jsonText = line.slice(5).trim()
if (!jsonText || jsonText === "[DONE]") continue
try {
const parsed = JSON.parse(jsonText)
if (parsed?.type === "answer" && parsed?.data) {
lastAnswerData = parsed.data
}
} catch (e) {
// 忽略非 JSON 片段,继续解析后续行
}
}
return lastAnswerData
}
const getAnalysisAnswerData = (raw) => {
if (!raw) return null
if (typeof raw === "string") {
return tryParseAnswerFromStreamText(raw)
}
if (typeof raw?.data === "string") {
return tryParseAnswerFromStreamText(raw.data)
}
if (raw.type === "answer" && raw.data) return raw.data
if (raw.data?.type === "answer" && raw.data?.data) return raw.data.data
if (raw.data?.consensus || raw.data?.differences) return raw.data
if (raw.consensus || raw.differences) return raw
return null
}
const consensusList = computed(() => {
const answer = getAnalysisAnswerData(domainViewAnalysisRes.value)
const list = Array.isArray(answer?.consensus) ? answer.consensus : []
return list.map((item, index) => {
const sourceViews = Array.isArray(item?.source_views) ? item.source_views : []
const sourceViewDetails = sourceViews
.map((v) => {
const detail = getViewpointDetailForSource(v.report_id, v.view_id)
return {
report_id: v.report_id,
view_id: v.view_id,
titleZh: detail.titleZh,
contentZh: detail.contentZh,
title: detail.title,
content: detail.content,
thinktankName: detail.thinktankName,
thinktankLogoUrl: detail.thinktankLogoUrl
}
})
.filter((sv) => {
const title = getSourceViewDisplayTitle(sv, 0)
return Boolean(title)
})
const sourceViewText = sourceViews.map((v) => `report_id: ${v.report_id}, view_id: ${v.view_id}`).join(";")
return {
id: `consensus-${index + 1}`,
consensusContent: item?.consensus_content || "",
reportCount: sourceViewDetails.length,
sourceViewText,
sourceViewDetails
}
})
})
const differenceList = computed(() => {
const answer = getAnalysisAnswerData(domainViewAnalysisRes.value)
const list = Array.isArray(answer?.differences) ? answer.differences : []
return list.map((item, index) => {
const sourceViews = Array.isArray(item?.source_views) ? item.source_views : []
const sourceViewDetails = sourceViews
.map((v) => {
const detail = getViewpointDetailForSource(v.report_id, v.view_id)
return {
report_id: v.report_id,
view_id: v.view_id,
titleZh: detail.titleZh,
contentZh: detail.contentZh,
title: detail.title,
content: detail.content,
thinktankName: detail.thinktankName,
thinktankLogoUrl: detail.thinktankLogoUrl
}
})
.filter((sv) => {
const title = getSourceViewDisplayTitle(sv, 0)
return Boolean(title)
})
const sourceViewText = sourceViews.map((v) => `report_id: ${v.report_id}, view_id: ${v.view_id}`).join(";")
return {
id: `difference-${index + 1}`,
disagreementContent: item?.disagreement_content || "",
reportCount: sourceViewDetails.length,
sourceViewText,
sourceViewDetails
}
})
})
// 近N年发布(用于 startDate)
const selectedYears = ref(5);
const yearsOptions = [
......@@ -399,16 +581,24 @@ const handleChoseItem = () => {
if (!canProceed.value) return
isChoseItem.value = false
}
const handleAnalysis = () => {
const handleAnalysis = async () => {
if (!canProceed.value) return
isBox2.value = false
isAnalysisLoading.value = true
isBeingAnalysisExpanded.value = false
beingAnalysisContent.value = ""
domainViewAnalysisRes.value = null
// 每次进入“开始分析”默认回到共识观点,避免上次状态残留导致内容错位
activeOpinionTab.value = 'consensus'
handlePostReportDomainViewAnalysis()
await handlePostReportDomainViewAnalysis()
}
const handleBack = () => {
isChoseItem.value = true
isBox2.value = true
// 返回选择时终止“分析中”展示,恢复空态图
isAnalysisLoading.value = false
isBeingAnalysisExpanded.value = false
beingAnalysisContent.value = ""
// 返回选择时也重置,确保下次进入分析展示一致
activeOpinionTab.value = 'consensus'
}
......@@ -445,47 +635,184 @@ const normalizeViewList = raw => {
const list = Array.isArray(raw) ? raw : []
return list
.filter(v => v && (v.view_id || v.viewId || v.id))
.map(v => ({
view_id: v.view_id ?? v.viewId ?? v.id,
view_text: v.view_text ?? v.viewText ?? v.text ?? ''
}))
.map(v => {
const viewId = v.view_id ?? v.viewId ?? v.id
const viewTextCandidate =
v.view_text ??
v.viewText ??
v.contentZh ??
v.contentEn ??
v.content ??
v.textZh ??
v.textEn ??
v.text ??
v.titleZh ??
v.titleEn ??
v.title ??
""
const viewText = String(viewTextCandidate ?? "").trim()
return {
view_id: viewId,
view_text: viewText
}
})
.filter(v => v.view_id != null && v.view_text !== "")
}
/** 从列表接口返回的报告项上取科技领域名称;没有再回退为左侧已选领域名,仍没有则用空字符串(不用「全部领域」) */
const getDomainsForReport = (report) => {
const raw = report?.domains
if (Array.isArray(raw) && raw.length) {
const names = raw.map((x) => String(x ?? "").trim()).filter(Boolean)
if (names.length) return [...new Set(names)]
}
if (selectedAreaList.value) {
const hit = Array.isArray(areaList.value)
? areaList.value.find((i) => String(i.id) === String(selectedAreaList.value))
: null
const name = String(hit?.name ?? "").trim()
if (name) return [name]
}
return [""]
}
const mergeAnalysisAnswerParts = (parts) => {
const consensus = []
const differences = []
for (const p of parts) {
const data = getAnalysisAnswerData(p)
if (!data) continue
if (Array.isArray(data.consensus)) consensus.push(...data.consensus)
if (Array.isArray(data.differences)) differences.push(...data.differences)
}
return { consensus, differences }
}
const handlePostReportDomainViewAnalysis = async () => {
try {
// 临时:先按接口文档示例写死,确保链路打通
const payload = {
domain: '人工智能',
report_view_list: [
{
report_id: 'Rand_RRA2877-1',
view_list: [
{
view_id: 35959,
view_text:
"We assess that metaverse technologies—that is, AI systems, immersive technologies, and enabling digital technologies—will continue to be integrated across new markets and create new demands for immersive experiences. We anticipate this will drive an increased TAV. We also assess that the metaverse concept is at an inflection point. As a result, uncertainties exist in assessing how the metaverse will expand, how large it will become, and whether it will become an expansive virtual world that directly competes with the physical world. We do assess that by the end of our study’s ten-year horizon, the conceptualization of this internet-like, immersive environment with virtual spaces will probably feel functionally more like an enhanced version of the IoT today than the metaverse that some have aspirationally described."
}
]
},
{
report_id: 'Rand_RRA3124-1',
view_list: [
{
view_id: 35826,
view_text:
"But even successful generalization to other present-day models cannot guarantee that the method will generalize well to future models, which may differ from the models of today in unexpected ways"
viewpointDetailByKey.value = {}
beingAnalysisContent.value = ""
const selectedReports = selectedReportList.value || []
const reportEntriesRaw = await Promise.all(
selectedReports.map(async (report) => {
const reportId = report?.id
if (!reportId) return null
try {
const viewpointRes = await getThinkTankReportViewpoint({
reportId,
currentPage: 0,
pageSize: 10000,
keyword: "",
orgIds: ""
})
console.log("分析时用到的观点", viewpointRes)
if (viewpointRes?.code !== 200 || !viewpointRes?.data) return null
const items = Array.isArray(viewpointRes.data.content)
? viewpointRes.data.content
: Array.isArray(viewpointRes.data)
? viewpointRes.data
: []
const detailMap = {}
for (const row of items) {
const vid = row?.id ?? row?.view_id ?? row?.viewId
if (vid == null || vid === "") continue
detailMap[`${reportId}::${String(vid)}`] = {
titleZh: String(row.titleZh ?? row.title_zh ?? "").trim(),
contentZh: String(row.contentZh ?? row.content_zh ?? "").trim(),
title: String(row.title ?? "").trim(),
content: String(row.content ?? "").trim(),
thinktankName: String(
row.thinktankName ??
row.thinktank_name ??
row.thinkTankName ??
row.orgName ??
""
).trim(),
thinktankLogoUrl: String(
row.thinktankLogoUrl ??
row.thinktank_logo_url ??
row.thinkTankLogoUrl ??
row.orgLogoUrl ??
""
).trim()
}
]
}
const views = normalizeViewList(items)
if (!views.length) return null
return {
report,
report_id: reportId,
view_list: views,
detailMap
}
} catch (error) {
console.error(`获取报告核心论点失败: ${reportId}`, error)
return null
}
]
})
)
const mergedDetails = {}
for (const e of reportEntriesRaw) {
if (e?.detailMap) Object.assign(mergedDetails, e.detailMap)
}
viewpointDetailByKey.value = mergedDetails
const reportEntries = reportEntriesRaw.filter(Boolean)
if (!reportEntries.length) {
domainViewAnalysisRes.value = null
return
}
const res = await postReportDomainViewAnalysis(payload)
console.log('智库领域观点分析接口返回', res)
domainViewAnalysisRes.value = res
// 按报告 domains 拆成多组 { domain, report_view_list };每个领域单独调一次接口(无最外层 domain_report_view_list
const domainToReports = new Map()
for (const entry of reportEntries) {
const domainLabels = getDomainsForReport(entry.report)
for (const domainLabel of domainLabels) {
if (!domainToReports.has(domainLabel)) {
domainToReports.set(domainLabel, [])
}
const list = domainToReports.get(domainLabel)
if (!list.some((x) => x.report_id === entry.report_id)) {
list.push({
report_id: entry.report_id,
view_list: entry.view_list
})
}
}
}
const payloads = [...domainToReports.entries()]
.filter(([, report_view_list]) => report_view_list.length > 0)
.map(([domain, report_view_list]) => ({ domain, report_view_list }))
if (!payloads.length) {
domainViewAnalysisRes.value = null
return
}
const responses = []
for (const payload of payloads) {
console.log("智库领域观点分析入参", payload)
const res = await postReportDomainViewAnalysisStream(payload, {
onReasoningChunk: async (chunk) => {
beingAnalysisContent.value += String(chunk)
await scrollBeingAnalysisToBottom()
}
})
responses.push(res)
}
const merged = mergeAnalysisAnswerParts(responses)
domainViewAnalysisRes.value = {
type: "answer",
data: merged
}
console.log("智库领域观点分析合并结果", merged)
} catch (e) {
console.error('智库领域观点分析接口调用失败', e)
domainViewAnalysisRes.value = null
} finally {
isAnalysisLoading.value = false
}
}
function arrayToString(value) {
......@@ -521,6 +848,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 +875,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 +885,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 +973,157 @@ onMounted(async () => {
}
}
.think-tank-link {
cursor: pointer;
}
.being-analysis-box {
width: 1063px;
height: 40px;
background-color: rgb(246, 250, 255);
border-radius: 50px;
border: 1px solid rgb(231, 243, 255);
display: flex;
.being-analysis-box-image {
width: 16px;
height: 16px;
margin-top: 12px;
margin-left: 12px;
img {
width: 100%;
height: 100%;
display: block;
}
}
.being-analysis-box-text {
font-family: "Source Han Sans CN";
font-weight: 700;
font-size: 14px;
line-height: 22px;
letter-spacing: 0;
text-align: left;
color: rgb(5, 95, 194);
height: 22px;
margin-left: 12px;
margin-top: 9px;
}
.being-analysis-box-change {
width: 14px;
height: 24px;
margin-top: 8px;
margin-left: auto;
margin-right: 12px;
cursor: pointer;
img {
width: 100%;
height: 100%;
display: block;
}
}
}
.being-analysis-detail-box {
width: 1063px;
height: 160px;
background-color: rgb(246, 250, 255);
border-radius: 10px;
display: flex;
flex-direction: column;
.being-analysis-box-header {
width: 1063px;
height: 40px;
background-color: rgb(246, 250, 255);
display: flex;
.being-analysis-box-image {
width: 16px;
height: 16px;
margin-top: 12px;
margin-left: 12px;
img {
width: 100%;
height: 100%;
display: block;
}
}
.being-analysis-box-text {
font-family: "Source Han Sans CN";
font-weight: 700;
font-size: 14px;
line-height: 22px;
letter-spacing: 0;
text-align: left;
color: rgb(5, 95, 194);
height: 22px;
margin-left: 12px;
margin-top: 9px;
}
.being-analysis-box-change {
width: 14px;
height: 24px;
margin-top: 8px;
margin-left: auto;
margin-right: 12px;
cursor: pointer;
img {
width: 100%;
height: 100%;
display: block;
}
}
}
.being-analysis-box-content {
width: 983px;
height: 104px;
margin-left: 40px;
overflow-y: auto;
overflow-x: hidden;
font-family: "Source Han Sans CN", sans-serif;
font-weight: 400;
font-size: 14px;
line-height: 22px;
letter-spacing: 0;
text-align: left;
color: rgb(59, 65, 75);
}
}
.being-analysis-content-text {
line-height: 22px;
white-space: pre-wrap;
word-break: break-word;
}
.being-analysis-image {
width: 100%;
height: 100%;
min-height: 320px;
display: flex;
top: 276px;
left: 408px;
position: absolute;
img {
width: 289px;
height: 242px;
display: block;
}
}
.box {
display: flex;
gap: 16px;
......@@ -1193,12 +1696,14 @@ onMounted(async () => {
height: 866px;
width: 100%;
display: flex;
justify-content: center;
position: relative;
.empty-image {
width: 289px;
height: 215px;
height: 242px;
margin-top: 276px;
justify-content: center;
display: flex;
......@@ -1216,7 +1721,7 @@ onMounted(async () => {
width: 100%;
display: flex;
padding-top: 4px;
padding-top: 13px;
padding-left: 24px;
padding-right: 24px;
padding-bottom: 18px;
......@@ -1304,15 +1809,53 @@ onMounted(async () => {
.box2-main-Consensus-content {
width: 1056px;
overflow-y: auto;
margin-top: 16px;
height: 794px;
overflow-x: hidden;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow-x: hidden;
align-items: stretch;
position: relative;
.empty-text {
font-family: "Source Han Sans CN";
font-weight: 400;
font-size: 16px;
line-height: 30px;
letter-spacing: 0px;
text-align: center;
display: flex;
width: 289px;
height: 30px;
position: absolute;
justify-content: center;
left: 384px;
bottom: 330px;
color: rgb(132, 136, 142);
}
/* 展开区向下撑开,子项不被纵向压缩;超出高度由本容器滚动 */
.empty-image {
height: 200px;
width: 200px;
position: absolute;
left: 428px;
bottom: 372px;
img {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
/* 防止图片变形 */
}
}
.Consensus-item {
width: 1056px;
min-height: 62px;
flex-shrink: 0;
box-sizing: border-box;
display: flex;
flex-direction: row;
......@@ -1398,12 +1941,13 @@ onMounted(async () => {
.Consensus-expand {
width: 1056px;
flex-shrink: 0;
box-sizing: border-box;
border-top: none;
padding: 16px 50px 24px 58px;
padding: 0px 50px 24px 58px;
}
.Consensus-expand:last-child {
......@@ -1493,6 +2037,82 @@ onMounted(async () => {
border-bottom: 1px solid rgb(234, 236, 238);
}
.source-view-detail {
margin-top: 16px;
padding-bottom: 16px;
border-bottom: 1px solid rgb(234, 236, 238);
&:last-child {
border-bottom: none;
}
}
.source-view-detail-title {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
font-family: "Source Han Sans CN";
font-weight: 500;
font-size: 15px;
line-height: 24px;
color: rgb(59, 65, 75);
margin-bottom: 6px;
}
.source-view-detail-title-text {
justify-content: space-between;
gap: 16px;
font-family: "Source Han Sans CN";
font-weight: 700;
font-size: 16px;
line-height: 30px;
letter-spacing: 0;
text-align: justify;
}
.source-view-detail-org {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 4px;
color: rgb(95, 101, 108);
font-size: 14px;
line-height: 22px;
margin-top: 4px;
img {
width: 16px;
height: 16px;
display: block;
object-fit: cover;
margin-top: 3px;
}
.source-view-detail-org-text {
font-family: "Source Han Sans CN";
font-weight: 400;
/* Regular */
font-size: 14px;
line-height: 22px;
letter-spacing: 0px;
text-align: left;
/* 左对齐 */
color: rgb(95, 101, 108);
}
}
.source-view-detail-content {
font-family: "Source Han Sans CN";
font-weight: 400;
font-size: 16px;
line-height: 30px;
letter-spacing: 0;
text-align: justify;
color: rgb(95, 101, 108);
}
}
......@@ -1504,7 +2124,7 @@ onMounted(async () => {
width: 100%;
display: flex;
padding-top: 4px;
padding-top: 13px;
padding-left: 24px;
padding-right: 24px;
padding-bottom: 18px;
......@@ -1514,14 +2134,17 @@ onMounted(async () => {
.box2-main-Differences-content {
width: 1056px;
overflow-y: auto;
height: 848px;
display: flex;
flex-direction: column;
overflow-x: hidden;
position: relative;
.Differences-item {
width: 1056px;
min-height: 62px;
flex-shrink: 0;
box-sizing: border-box;
display: flex;
flex-direction: row;
......@@ -1534,6 +2157,39 @@ onMounted(async () => {
}
.empty-image {
height: 200px;
width: 200px;
position: absolute;
left: 428px;
bottom: 372px;
img {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
/* 防止图片变形 */
}
}
.empty-text {
font-family: "Source Han Sans CN";
font-weight: 400;
font-size: 16px;
line-height: 30px;
letter-spacing: 0px;
text-align: center;
display: flex;
width: 289px;
height: 30px;
position: absolute;
justify-content: center;
left: 384px;
bottom: 330px;
color: rgb(132, 136, 142);
}
.Differences-item:last-child {
border-bottom: 1px solid rgb(234, 236, 238);
}
......@@ -1563,8 +2219,8 @@ onMounted(async () => {
}
.Differences-item-title {
width: 958px;
flex: 1;
min-width: 0;
min-height: 30px;
margin-left: 18px;
......@@ -1576,36 +2232,25 @@ onMounted(async () => {
text-align: justify;
color: rgb(59, 65, 75);
display: flex;
gap: 36px
}
.Differences-item:has(+ .Differences-expand) .Differences-item-title {
.Differences-item:has(+ .Differences-expand) .Differences-item-title-left {
font-weight: 700;
}
.Differences-item-title-right {
min-height: 30px;
width: 461px;
margin-left: 18px;
width: 89px;
}
.Differences-item-title-left {
min-height: 30px;
width: 461px;
width: 845px;
}
.Differences-item-right {
flex: 0 0 auto;
width: 89px;
height: 30px;
margin-left: 18px;
color: rgb(95, 101, 108);
font-family: "Source Han Sans CN";
font-weight: 400;
font-size: 16px;
line-height: 30px;
letter-spacing: 0;
text-align: justify;
}
.Differences-item-image {
flex: 0 0 auto;
......@@ -1622,12 +2267,13 @@ onMounted(async () => {
.Differences-expand {
width: 1056px;
flex-shrink: 0;
box-sizing: border-box;
border-top: none;
padding: 22px 50px 24px 58px;
padding: 0px 50px 24px 58px;
display: flex;
}
......@@ -1648,14 +2294,14 @@ onMounted(async () => {
}
.Differences-expand-top-content-left {
width: 452px;
width: 948px;
display: flex;
flex-direction: column;
}
.content-left-title {
width: 452px;
width: 948px;
height: 24px;
display: flex;
overflow: hidden;
......@@ -1671,7 +2317,7 @@ onMounted(async () => {
}
.content-left-img-name {
width: 452px;
width: 948px;
height: 22px;
display: flex;
margin-top: 8px;
......@@ -1717,76 +2363,82 @@ onMounted(async () => {
}
.differences-source-views {
.content-right-title {
width: 452px;
height: 24px;
display: flex;
/* 关键:超出宽度显示 ... */
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: rgb(59, 65, 75);
font-family: "Source Han Sans CN";
font-weight: 400 !important;
font-size: 16px;
line-height: 24px;
letter-spacing: 0px;
text-align: justify;
}
.content-right-img-name {
width: 452px;
height: 22px;
display: flex;
margin-top: 8px;
}
.source-view-detail {
margin-top: 16px;
padding-bottom: 16px;
border-bottom: 1px solid rgb(234, 236, 238);
}
.content-right-img {
width: 16px;
height: 16px;
margin-top: 3px;
margin-right: 4px;
.source-view-detail-title {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
font-family: "Source Han Sans CN";
font-weight: 500;
font-size: 15px;
line-height: 24px;
color: rgb(59, 65, 75);
margin-bottom: 6px;
}
img {
width: 100%;
height: 100%;
display: block;
.source-view-detail-title-text {
justify-content: space-between;
font-family: "Source Han Sans CN", sans-serif;
font-weight: 700;
font-size: 16px;
line-height: 30px;
letter-spacing: 0;
text-align: justify;
}
}
.content-right-name {
.source-view-detail-org {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 4px;
color: rgb(95, 101, 108);
font-size: 14px;
line-height: 22px;
margin-top: 4px;
height: 22px;
font-family: "Source Han Sans CN";
font-weight: 400;
font-size: 14px;
line-height: 22px;
letter-spacing: 0;
text-align: left;
color: rgb(95, 101, 108);
}
img {
width: 16px;
height: 16px;
display: block;
.Differences-expand-top-content-right {
width: 452px;
display: flex;
flex-direction: column;
object-fit: cover;
margin-top: 3px;
}
}
.source-view-detail-org-text {
font-family: "Source Han Sans CN";
font-weight: 400;
/* Regular */
font-size: 14px;
line-height: 22px;
letter-spacing: 0px;
text-align: left;
/* 左对齐 */
color: rgb(95, 101, 108);
}
}
.content-right-text {
font-family: "Source Han Sans CN";
font-weight: 400;
font-size: 16px;
line-height: 30px;
letter-spacing: 0px;
text-align: justify;
color: rgb(95, 101, 108);
margin-top: 8px;
.source-view-detail-content {
font-family: "Source Han Sans CN";
font-weight: 400;
font-size: 16px;
line-height: 30px;
letter-spacing: 0;
text-align: justify;
color: rgb(95, 101, 108);
}
}
.Differences-expand-view {
width: 948px;
height: 36px;
......@@ -1818,28 +2470,6 @@ onMounted(async () => {
justify-content: center;
}
.Differences-expand-view-right {
width: 452px;
height: 36px;
}
.right-tag {
width: 112px;
height: 28px;
color: rgb(5, 95, 194);
background-color: rgb(231, 243, 255);
border-radius: 4px;
margin-top: 8px;
font-family: "Source Han Sans CN";
font-weight: 400;
font-size: 16px;
line-height: 24px;
letter-spacing: 0px;
text-align: justify;
display: flex;
align-items: center;
justify-content: center;
}
}
......
......@@ -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 {
......
<template>
<div class="wrap">
<div class="top">
<WarningPane :warnningLevel="riskSignal?.level" :warnningContent="riskSignal?.content">
<WarningPane :warnningLevel="riskSignal?.level" :warnningContent="riskSignal?.content" v-if="riskSignal?.level">
</WarningPane>
</div>
<div class="bottom-row">
......@@ -52,8 +52,9 @@
</div>
<div class="author-box">
<div class="author-item" v-for="(author, idx) in reportAuthors" :key="idx"
v-if="Array.isArray(reportAuthors) && reportAuthors.length">
<div class="image"><img :src="author.avatar" alt="" /></div>
v-if="Array.isArray(reportAuthors) && reportAuthors.length" @click="handleClickReportAuthor(author)">
<div class="image"><img :src="author.avatar ? author.avatar : DefaultIcon1" alt=""
@error="() => { if (author.avatar) author.avatar = null; }" /></div>
<div class="author-text">
<div class="author-name">{{ author.name }}</div>
<div class="author-position">{{ author.job }}</div>
......@@ -66,7 +67,21 @@
<div class="box5">
<AnalysisBox title="报告关键词云" :showAllBtn="true">
<div class="box5-main">
<div id="box5Chart"></div>
<div class="box5Chart">
<!-- 有数据后再挂载子组件:子组件仅在 onMounted 初始化,异步数据到达后需 v-if + key 强制重新挂载 -->
<WordCloudChart v-if="box5Data.length" :key="box5WordCloudKey" :data="box5Data" width="100%"
height="100%" />
</div>
<div class="box5-footer">
<TipTab :text="REPORT_ANALYSIS_TIP_BOX5" />
<div class="ai-wrap" @mouseenter="handleSwitchAiContentShowBox5(true)">
<AiButton />
</div>
</div>
<div class="ai-content" v-if="isShowAiContentBox5" @mouseleave="handleSwitchAiContentShowBox5(false)">
<AiPane :aiContent="aiContentBox5" />
</div>
</div>
</AnalysisBox>
</div>
......@@ -141,27 +156,27 @@
</div>
<div class="box4-main">
<div class="box4-main-main">
<div class="box4-item" v-for="(item, index) in filteredOpinions" :key="index">
<div class="box4-item" v-for="(item, index) in filteredOpinions"
:key="item.id != null ? item.id : index">
<div class="top-row">
<div class="left">
{{ index + 1 }}
</div>
<div class="center">
<div class="title">{{ item.titleZh }}</div>
<div class="title" v-html="highlightOpinionText(item.titleZh)"></div>
<div>
<img src="../images/image-open.png" alt="" class="center-image"
@click="handleOpenReportOriginal(item)" />
</div>
<div>
<img v-if="expandedIndex !== index" src="../images/image-down.png" alt="" class="center-image"
@click="toggleOpinion(index)" />
<img v-if="!isOpinionExpanded(item, index)" src="../images/image-down.png" alt=""
class="center-image" @click="toggleOpinion(item, index)" />
<img v-else src="../images/image-up.png" alt="" class="center-image"
@click="toggleOpinion(index)" />
@click="toggleOpinion(item, index)" />
</div>
</div>
</div>
<div v-if="expandedIndex === index" class="desc">
{{ item.contentZh }}
<div v-if="isOpinionExpanded(item, index)" class="desc" v-html="highlightOpinionText(item.contentZh)">
</div>
<!-- <div class="right"> -->
<!-- <div class="tag" v-for="(val, idx) in item.hylyList" :key="idx">
......@@ -193,11 +208,12 @@
</template>
<script setup>
import DefaultIcon1 from '@/assets/icons/default-icon1.png'
import WarningPane from "@/components/base/WarningPane/index.vue"
import WordCloudChart from "@/components/base/WordCloundChart/index.vue"
import SearchContainer from "@/components/SearchContainer.vue";
import { ref, onMounted, computed, defineProps, nextTick } from "vue";
import setChart from "@/utils/setChart";
import getWordCloudChart from "./utils/worldCloudChart";
import { ref, onMounted, computed, defineProps } from "vue";
import { ElMessage } from "element-plus";
import {
getThinkTankReportAbstract,
getThinkTankReportContent,
......@@ -209,6 +225,10 @@ import { getChartAnalysis } from "@/api/aiAnalysis/index";
import { useRouter } from "vue-router";
import "echarts-wordcloud";
import AiSummary from '@/components/base/Ai/AiSummary/index.vue'
import { getPersonSummaryInfo } from "@/api/common/index";
import AiButton from "@/components/base/Ai/AiButton/index.vue";
import AiPane from "@/components/base/Ai/AiPane/index.vue";
import TipTab from "@/views/thinkTank/TipTab/index.vue";
const router = useRouter();
......@@ -232,27 +252,73 @@ const props = defineProps({
default: () => ({})
}
});
const REPORT_ANALYSIS_TIP_BOX5 =
"智库报告关键词云,数据来源:美国兰德公司官网";
const isShowAiContentBox5 = ref(false);
const aiContentBox5 = ref("");
const isBox5InterpretLoading = ref(false);
const handleSwitchAiContentShowBox5 = (val) => {
isShowAiContentBox5.value = val;
if (val) {
fetchBox5ChartInterpretation();
}
};
const searchOpinions = ref('');
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 highlightOpinionText = (text) => {
const safeText = escapeHtml(text);
const keyword = (searchOpinions.value || "").trim();
if (!keyword) return safeText;
const pattern = new RegExp(`(${escapeRegExp(keyword)})`, "gi");
return safeText.replace(pattern, `<span class="opinion-keyword-highlight">$1</span>`);
};
const handleSearchOpinions = () => {
currentPage.value = 1;
handleGetThinkTankReportViewpoint();
};
// 当前展开的核心论点下标(同一时刻只展开一条)
const expandedIndex = ref(null);
/** 可同时展开多条;用 id 区分项,避免翻页后索引与展开状态错位 */
const expandedOpinionKeys = ref(new Set());
const filteredOpinions = computed(() => majorOpinions.value);
const opinionsTotal = computed(() => total.value);
const toggleOpinion = index => {
if (expandedIndex.value === index) {
expandedIndex.value = null;
const getOpinionExpandKey = (item, index) => {
if (item != null && item.id != null && item.id !== "") {
return String(item.id);
}
return `idx-${index}`;
};
const isOpinionExpanded = (item, index) => {
return expandedOpinionKeys.value.has(getOpinionExpandKey(item, index));
};
const toggleOpinion = (item, index) => {
const key = getOpinionExpandKey(item, index);
const next = new Set(expandedOpinionKeys.value);
if (next.has(key)) {
next.delete(key);
} else {
expandedIndex.value = index;
next.add(key);
}
expandedOpinionKeys.value = next;
};
const publishTime = computed(() => {
......@@ -277,6 +343,62 @@ const reportAuthors = computed(() => {
}
return [];
});
// 点击报告作者头像,跳转到人物主页
// 与核心研究人员逻辑一致:核心依赖 personId,本页面依赖作者的 id(作为 personId 传入)
const handleClickReportAuthor = async (author) => {
const personId = author?.id;
if (!personId) return;
let personTypeList = [];
try {
personTypeList = JSON.parse(window.sessionStorage.getItem("personTypeList") || "[]");
} catch (e) {
personTypeList = [];
}
const params = { personId };
try {
const res = await getPersonSummaryInfo(params);
if (res.code !== 200 || !res.data) return;
const arr = personTypeList.filter((t) => {
const typeIdNum = Number(t.typeId);
const personTypeNum = Number(res.data.personType);
return !Number.isNaN(typeIdNum) && !Number.isNaN(personTypeNum) && typeIdNum === personTypeNum;
});
if (!arr.length) {
ElMessage.warning("找不到当前人员的类型值!");
return;
}
const personTypeName = arr[0]?.typeName || "";
let type = 0;
if (personTypeName === "科技企业领袖") {
type = 1;
} else if (personTypeName === "国会议员") {
type = 2;
} else if (personTypeName === "智库研究人员") {
type = 3;
} else {
ElMessage.warning("找不到当前人员的类型值!");
return;
}
window.sessionStorage.setItem("curTabName", author?.name || "");
const route = router.resolve({
path: "/characterPage",
query: {
type,
personId
}
});
window.open(route.href, "_blank");
} catch (error) {
console.error("点击报告作者头像跳转失败", error);
}
};
const riskSignal = computed(() => {
const info = props.thinkInfo || {};
return info.riskSignal;
......@@ -345,6 +467,8 @@ const box2Data = ref([
]);
// 报告关键词云
const box5Data = ref([]);
/** 词云子组件不 watch 数据,每次接口成功有数据时递增 key,强制重新挂载以触发 onMounted */
const box5WordCloudKey = ref(0);
//获取科技领域词云
const handleGetThinkTankReportIndustryCloud = async () => {
......@@ -362,10 +486,9 @@ const handleGetThinkTankReportIndustryCloud = async () => {
}));
// 该接口数据用于「报告关键词云」
box5Data.value = data;
await nextTick();
const box5Chart = getWordCloudChart(box5Data.value);
setChart(box5Chart, "box5Chart");
if (data.length) {
box5WordCloudKey.value += 1;
}
} else {
box5Data.value = [];
}
......@@ -462,7 +585,7 @@ const handleGetThinkTankReportViewpoint = async () => {
total.value = res.data.totalElements || 0;
// 重置展开状态
expandedIndex.value = null;
expandedOpinionKeys.value = new Set();
}
} catch (error) {
console.error("获取主要观点error", error);
......@@ -478,6 +601,67 @@ const handleGetBox3AnalysisContent = async textJson => {
const res = await getChartAnalysis(params);
console.log("图表解析内容", res);
};
const getInterpretationTextFromChartResponse = (res) => {
const list = res?.data;
const first = Array.isArray(list) ? list[0] : null;
return (
first?.["解读"] ||
first?.["interpretation"] ||
first?.["analysis"] ||
first?.["content"] ||
""
);
};
const appendAiInterpretationChunk = (targetRef, chunk, loadingText = "解读生成中…") => {
if (!chunk) {
return;
}
const current = String(targetRef.value || "");
const base = current === loadingText ? "" : current;
targetRef.value = base + String(chunk);
};
const fetchBox5ChartInterpretation = async () => {
const list = Array.isArray(box5Data.value) ? box5Data.value : [];
if (!list.length) {
aiContentBox5.value = "暂无图表数据";
return;
}
const hasValidContent =
aiContentBox5.value &&
aiContentBox5.value !== "解读生成中…" &&
aiContentBox5.value !== "解读加载失败" &&
aiContentBox5.value !== "暂无图表数据";
if (hasValidContent || isBox5InterpretLoading.value) {
return;
}
isBox5InterpretLoading.value = true;
aiContentBox5.value = "解读生成中…";
const chartPayload = {
type: "词云图",
name: "报告关键词云",
data: list.map((item) => ({
name: item.name,
value: item.value
}))
};
try {
const res = await getChartAnalysis(
{ text: JSON.stringify(chartPayload) },
{
onChunk: chunk => {
appendAiInterpretationChunk(aiContentBox5, chunk);
}
}
);
const text = getInterpretationTextFromChartResponse(res);
aiContentBox5.value = text || aiContentBox5.value || "未返回有效解读内容";
} catch (error) {
console.error("报告关键词云图表解读请求失败", error);
aiContentBox5.value = "解读加载失败";
} finally {
isBox5InterpretLoading.value = false;
}
};
onMounted(() => {
handleGetThinkTankReportAbstract();
......@@ -492,7 +676,7 @@ onMounted(() => {
.wrap {
display: flex;
justify-content: center;
gap: 16px;
padding-bottom: 16px;
flex-direction: column;
......@@ -520,7 +704,7 @@ onMounted(() => {
.box1 {
margin-top: 17px;
width: 480px;
height: 486px;
// border: 1px solid rgba(234, 236, 238, 1);
// border-radius: 10px;
......@@ -528,7 +712,7 @@ onMounted(() => {
// background: rgba(255, 255, 255, 1);
.box1-main {
width: 480px;
height: 438px;
.text-box {
width: 437px;
......@@ -635,8 +819,8 @@ onMounted(() => {
.author-box {
width: 437px;
height: auto;
/* 改为自适应高度,不要固定 220px */
max-height: 220px;
margin-top: 34px;
margin-left: 18px;
display: grid;
......@@ -646,6 +830,7 @@ onMounted(() => {
/* 左右间距(同一行) */
row-gap: 8px;
/* 上下间距(同一列) */
margin-bottom: 38px;
.author-item {
width: 213px;
......@@ -659,6 +844,7 @@ onMounted(() => {
margin-top: 3px;
margin-left: 3px;
display: inline-block;
cursor: pointer;
img {
......@@ -715,6 +901,7 @@ onMounted(() => {
width: 480px;
height: 415px;
.box5-main {
width: 480px;
height: 361px;
......@@ -723,22 +910,51 @@ onMounted(() => {
padding-top: 26px;
padding-bottom: 43px;
display: flex;
flex-direction: column;
box-sizing: border-box;
overflow: hidden;
position: relative;
#box5Chart {
width: 100%;
height: 100%;
.box5Chart {
width: 418px;
height: 292px;
margin: 0 auto;
overflow: hidden;
}
.box5-footer {
display: flex;
justify-content: space-between;
align-items: center;
position: absolute;
bottom: 18px;
right: 0;
}
.ai-content {
position: absolute;
bottom: 0;
right: 0;
min-width: 480px;
min-height: 156px;
}
.ai-wrap {
position: relative;
cursor: pointer;
}
}
}
.box2 {
width: 480px;
height: 648px;
// border: 1px solid rgba(234, 236, 238, 1);
// border-radius: 10px;
// box-shadow: 0px 0px 20px 0px rgba(25, 69, 130, 0.1);
......@@ -749,6 +965,7 @@ onMounted(() => {
margin-top: 5px;
margin-left: 23px;
.box2-item {
height: 103px;
width: 100%;
......@@ -834,6 +1051,7 @@ onMounted(() => {
.box2-btn {
margin-top: 16px;
margin-bottom: 21px;
margin-left: 23px;
width: 436px;
height: 36px;
......@@ -880,7 +1098,7 @@ onMounted(() => {
.box3 {
width: 1103px;
height: 890px;
// border: 1px solid rgba(234, 236, 238, 1);
// border-radius: 10px;
......@@ -888,13 +1106,24 @@ onMounted(() => {
// background: rgba(255, 255, 255, 1);
.box3-main {
width: 1058px;
height: 830px;
margin-top: 3px;
margin-left: 22px;
padding-bottom: 22px;
:deep(.summary-main) {
margin-bottom: 25px;
font-family: "Source Han Sans CN";
font-weight: 400;
/* Regular 常规 */
font-size: 16px;
line-height: 30px;
letter-spacing: 0px;
text-align: justify;
color: rgb(59, 65, 75);
/* 两端对齐 */
}
.box3-top {
width: 1058px;
......@@ -943,7 +1172,8 @@ onMounted(() => {
.box4 {
width: 1103px;
height: 965px;
height: auto;
// border: 1px solid rgba(234, 236, 238, 1);
// border-radius: 10px;
// box-shadow: 0px 0px 20px 0px rgba(25, 69, 130, 0.1);
......@@ -984,12 +1214,13 @@ onMounted(() => {
.box4-main {
width: 1057px;
height: auto;
margin: 0 auto;
.box4-main-main {
height: 767px;
overflow: hidden;
height: auto;
overflow: visible;
.box4-item {
width: 1057px;
......@@ -998,6 +1229,11 @@ onMounted(() => {
display: flex;
flex-direction: column;
position: relative;
border-bottom: 1px solid rgba(234, 236, 238, 1);
&:first-child {
border-top: 1px solid rgba(234, 236, 238, 1);
}
.top-row {
display: flex;
......@@ -1005,7 +1241,7 @@ onMounted(() => {
}
.left {
margin-top: 24px;
margin-top: 19px;
margin-left: 15px;
width: 24px;
height: 24px;
......@@ -1021,9 +1257,9 @@ onMounted(() => {
}
.center {
min-height: 64px;
min-height: 62px;
margin-left: 13px;
margin-left: 18px;
display: flex;
align-items: center;
// overflow: hidden;
......@@ -1031,14 +1267,14 @@ onMounted(() => {
// white-space: nowrap;
.title {
margin-top: 12px;
width: 918px;
// height: 55px;
color: rgba(59, 65, 75, 1);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 700;
line-height: 26px;
line-height: 30px;
letter-spacing: 0px;
text-align: left;
overflow: hidden;
......@@ -1101,9 +1337,10 @@ onMounted(() => {
}
.desc {
width: 950px;
margin-top: 22px;
margin-left: 52px; // 24(left) + 13(center margin) + 一点间距
padding-top: 22px;
padding-bottom: 23px;
padding-left: 56px; // 24(left) + 13(center margin) + 一点间距
color: rgb(59, 65, 75);
font-family: "Source Han Sans CN";
......@@ -1114,13 +1351,13 @@ onMounted(() => {
letter-spacing: 0px;
text-align: justify;
/* 两端对齐 */
border-top: 1px solid rgba(234, 236, 238, 1);
}
}
.box4-item {
border-bottom: 1px solid rgba(234, 236, 238, 1);
border-top: 1px solid rgba(234, 236, 238, 1);
.title :deep(.opinion-keyword-highlight),
.desc :deep(.opinion-keyword-highlight) {
background-color: #fff59d;
}
}
}
......
......@@ -120,15 +120,7 @@
</template>
<template v-else>
<div class="box3-chart-row">
<button v-if="box3LegendShowNav" type="button" class="box3-legend-nav-btn" :disabled="!box3LegendCanPrev"
aria-label="上一页图例" @click="handleBox3LegendPrev">
</button>
<div id="box3Chart" class="box3-chart-canvas"></div>
<button v-if="box3LegendShowNav" type="button" class="box3-legend-nav-btn" :disabled="!box3LegendCanNext"
aria-label="下一页图例" @click="handleBox3LegendNext">
</button>
</div>
<div class="source">
<TipTab :text="POLICY_TRACKING_TIP_BOX3" />
......@@ -188,17 +180,11 @@
<div class="title">{{ "科技领域" }}</div>
</div>
<div class="select-main">
<el-checkbox-group
class="checkbox-group"
v-model="selectedAreaList"
@change="handleChangePolicyAreaList">
<el-checkbox-group class="checkbox-group" v-model="selectedAreaList" @change="handleChangePolicyAreaList">
<el-checkbox class="filter-checkbox" :label="POLICY_FILTER_ALL_AREA">
{{ POLICY_FILTER_ALL_AREA }}
</el-checkbox>
<el-checkbox
class="filter-checkbox"
v-for="research in areaList"
:key="research.id"
<el-checkbox class="filter-checkbox" v-for="research in areaList" :key="research.id"
:label="research.id">
{{ research.name }}
</el-checkbox>
......@@ -211,17 +197,12 @@
<div class="title">{{ "涉及部门" }}</div>
</div>
<div class="select-main">
<el-checkbox-group
class="checkbox-group"
v-model="selectedDepartmentList"
<el-checkbox-group class="checkbox-group" v-model="selectedDepartmentList"
@change="handleChangePolicyDepartmentList">
<el-checkbox class="filter-checkbox" :label="POLICY_FILTER_ALL_DEPT">
{{ POLICY_FILTER_ALL_DEPT }}
</el-checkbox>
<el-checkbox
class="filter-checkbox"
v-for="department in selectableDepartment"
:key="department.id"
<el-checkbox class="filter-checkbox" v-for="department in selectableDepartment" :key="department.id"
:label="department.id">
{{ department.name }}
</el-checkbox>
......@@ -234,18 +215,11 @@
<div class="title">{{ "发布时间" }}</div>
</div>
<div class="select-main">
<el-checkbox-group
class="checkbox-group"
v-model="selectedYearList"
@change="handleChangePolicyYearList">
<el-checkbox-group class="checkbox-group" v-model="selectedYearList" @change="handleChangePolicyYearList">
<el-checkbox class="filter-checkbox" :label="POLICY_FILTER_ALL_TIME">
{{ POLICY_FILTER_ALL_TIME }}
</el-checkbox>
<el-checkbox
class="filter-checkbox"
v-for="year in selectableYears"
:key="year"
:label="year">
<el-checkbox class="filter-checkbox" v-for="year in selectableYears" :key="year" :label="year">
{{ year }}
</el-checkbox>
</el-checkbox-group>
......@@ -260,10 +234,10 @@
</div>
<div class="item-right">
<div>
<div class="title">{{ item.titleZh }}</div>
<div class="title" v-html="highlightPolicyText(item.titleZh)"></div>
<div class="info">
<div class="text">
{{ item.reportDateDisplay }}·{{ item.contentZh }}
<span v-html="highlightPolicyText(`${item.reportDateDisplay}·${item.contentZh}`)"></span>
</div>
<div class="more" @click="toDetail(item)">
......@@ -313,7 +287,7 @@
import { ref, watch, onMounted, computed, nextTick } from "vue";
import setChart from "@/utils/setChart";
import getPieChart from "./utils/piechart";
import getMultiLineChart, { POLICY_TRACKING_LEGEND_PAGE_SIZE } from "./utils/multiLineChart";
import getMultiLineChart from "./utils/multiLineChart";
import {
getThinkPolicyIndustry,
getThinkPolicyIndustryTotal,
......@@ -594,41 +568,7 @@ const hasBox3ChartData = computed(() => {
);
});
const box3LegendPageIndex = ref(0);
const box3LegendPageCount = computed(() => {
const n = box3Data.value?.data?.length ?? 0;
return Math.max(1, Math.ceil(n / POLICY_TRACKING_LEGEND_PAGE_SIZE));
});
const box3LegendShowNav = computed(() => {
const n = box3Data.value?.data?.length ?? 0;
return n > POLICY_TRACKING_LEGEND_PAGE_SIZE;
});
const box3LegendCanPrev = computed(() => box3LegendPageIndex.value > 0);
const box3LegendCanNext = computed(
() => box3LegendPageIndex.value < box3LegendPageCount.value - 1
);
const handleBox3LegendPrev = () => {
if (box3LegendPageIndex.value <= 0) {
return;
}
box3LegendPageIndex.value -= 1;
renderBox3Chart();
};
const handleBox3LegendNext = () => {
if (box3LegendPageIndex.value >= box3LegendPageCount.value - 1) {
return;
}
box3LegendPageIndex.value += 1;
renderBox3Chart();
};
/** 研究领域变化趋势:图例分页与概览「数量变化趋势」一致 */
/** 研究领域变化趋势:图例不分页,超多时最多三行显示 */
const renderBox3Chart = async () => {
const v = box3Data.value;
if (
......@@ -640,22 +580,8 @@ const renderBox3Chart = async () => {
) {
return;
}
const totalNames = v.data.length;
const pages = Math.max(1, Math.ceil(totalNames / POLICY_TRACKING_LEGEND_PAGE_SIZE));
if (box3LegendPageIndex.value >= pages) {
box3LegendPageIndex.value = pages - 1;
}
if (box3LegendPageIndex.value < 0) {
box3LegendPageIndex.value = 0;
}
await nextTick();
const box3Chart = getMultiLineChart(
{ title: v.title, data: v.data },
{
legendShowCount: POLICY_TRACKING_LEGEND_PAGE_SIZE,
legendPageIndex: box3LegendPageIndex.value
}
);
const box3Chart = getMultiLineChart({ title: v.title, data: v.data });
setChart(box3Chart, "box3Chart");
};
......@@ -729,7 +655,6 @@ const handleGetThinkPolicyIndustryChange = async () => {
return;
}
box3Data.value = frontendData;
box3LegendPageIndex.value = 0;
await renderBox3Chart();
} else {
box3Data.value = { title: [], data: [] };
......@@ -740,6 +665,29 @@ const handleGetThinkPolicyIndustryChange = async () => {
}
};
/** 兼容 getChartAnalysis 返回对象:从 data[0] 提取「解读」文本 */
const getInterpretationTextFromChartResponse = (res) => {
const list = res?.data;
const first = Array.isArray(list) ? list[0] : null;
return (
first?.["解读"] ||
first?.["interpretation"] ||
first?.["analysis"] ||
first?.["content"] ||
""
);
};
/** 参考科技法案数量变化趋势:按 chunk 增量拼接展示 */
const appendAiInterpretationChunk = (targetRef, chunk, loadingText = "解读生成中…") => {
if (!chunk) {
return;
}
const current = String(targetRef.value || "");
const base = current === loadingText ? "" : current;
targetRef.value = base + String(chunk);
};
/** 政策追踪-领域分布饼图解读(与概览页 box6 入参一致) */
const fetchPolicyPtBox1ChartInterpretation = async () => {
const list = Array.isArray(box1Data.value) ? box1Data.value : [];
......@@ -767,17 +715,16 @@ const fetchPolicyPtBox1ChartInterpretation = async () => {
}))
};
try {
const text = await getChartAnalysis(
const res = await getChartAnalysis(
{ text: JSON.stringify(chartPayload) },
{
onInterpretationDelta: (chunk) => {
if (chunk) {
aiContentPolicyPt1.value = chunk;
}
onChunk: chunk => {
appendAiInterpretationChunk(aiContentPolicyPt1, chunk);
}
}
);
aiContentPolicyPt1.value = text || "未返回有效解读内容";
const text = getInterpretationTextFromChartResponse(res);
aiContentPolicyPt1.value = text || aiContentPolicyPt1.value || "未返回有效解读内容";
} catch (error) {
console.error("政策追踪领域分布图表解读请求失败", error);
aiContentPolicyPt1.value = "解读加载失败";
......@@ -813,17 +760,16 @@ const fetchPolicyPtBox2ChartInterpretation = async () => {
}))
};
try {
const text = await getChartAnalysis(
const res = await getChartAnalysis(
{ text: JSON.stringify(chartPayload) },
{
onInterpretationDelta: (chunk) => {
if (chunk) {
aiContentPolicyPt2.value = chunk;
}
onChunk: chunk => {
appendAiInterpretationChunk(aiContentPolicyPt2, chunk);
}
}
);
aiContentPolicyPt2.value = text || "未返回有效解读内容";
const text = getInterpretationTextFromChartResponse(res);
aiContentPolicyPt2.value = text || aiContentPolicyPt2.value || "未返回有效解读内容";
} catch (error) {
console.error("政策追踪部门分布图表解读请求失败", error);
aiContentPolicyPt2.value = "解读加载失败";
......@@ -867,17 +813,16 @@ const fetchPolicyPtBox3ChartInterpretation = async () => {
})
};
try {
const text = await getChartAnalysis(
const res = await getChartAnalysis(
{ text: JSON.stringify(chartPayload) },
{
onInterpretationDelta: (chunk) => {
if (chunk) {
aiContentPolicyPt3.value = chunk;
}
onChunk: chunk => {
appendAiInterpretationChunk(aiContentPolicyPt3, chunk);
}
}
);
aiContentPolicyPt3.value = text || "未返回有效解读内容";
const text = getInterpretationTextFromChartResponse(res);
aiContentPolicyPt3.value = text || aiContentPolicyPt3.value || "未返回有效解读内容";
} catch (error) {
console.error("政策追踪研究领域趋势图表解读请求失败", error);
aiContentPolicyPt3.value = "解读加载失败";
......@@ -889,6 +834,27 @@ const fetchPolicyPtBox3ChartInterpretation = async () => {
// 搜索政策建议
const searchPolicy = ref("");
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 highlightPolicyText = (text) => {
const safeText = escapeHtml(text);
const keyword = (searchPolicy.value || "").trim();
if (!keyword) return safeText;
const pattern = new RegExp(`(${escapeRegExp(keyword)})`, "gi");
return safeText.replace(pattern, '<span class="keyword-highlight">$1</span>');
};
const yearList = ref([
{
label: "近一年发布",
......@@ -1507,40 +1473,10 @@ onMounted(() => {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 8px;
width: 100%;
box-sizing: border-box;
}
.box3-legend-nav-btn {
flex-shrink: 0;
width: 32px;
height: 32px;
margin-top: 6px;
padding: 0;
box-sizing: border-box;
border: 1px solid rgba(220, 223, 230, 1);
border-radius: 6px;
background: rgba(255, 255, 255, 1);
color: rgba(59, 65, 75, 1);
font-size: 20px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.box3-legend-nav-btn:hover:not(:disabled) {
border-color: var(--color-main-primary, #055fc2);
color: var(--color-main-primary, #055fc2);
}
.box3-legend-nav-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.box3-chart-canvas {
flex: 1;
min-width: 0;
......@@ -2128,6 +2064,10 @@ onMounted(() => {
}
}
:deep(.keyword-highlight) {
background-color: #fff59d;
}
.tag-box {
margin-top: 7px;
display: flex;
......@@ -2237,4 +2177,8 @@ onMounted(() => {
height: 8px !important;
margin-right: 4px;
}
:deep(.el-checkbox__label) {
font-size: 16px;
}
</style>
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()" />
......@@ -60,16 +62,17 @@
<div class="title">{{ "听证会部门" }}</div>
</div>
<div class="select-main">
<div class="checkbox-group">
<el-checkbox class="filter-checkbox" :model-value="isGroupAllSelected(researchHearingIds)"
@change="val => handleToggleAll(val, researchHearingIds)">
全部部门
<el-checkbox-group
class="checkbox-group"
: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="handleGetThinkDynamicsReport()">
<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 class="input-main">
<el-input placeholder="输入作者名字" v-model="author" @input="handleGetThinkDynamicsReport()" />
......@@ -84,11 +87,10 @@
<img class="card-item-img" src="../images/img congress.png" alt="report image" />
<div class="card-item-text">
<div class="card-item-title">
{{ item.title }}
<span v-html="highlightText(item.title)"></span>
</div>
<div class="card-item-time">
{{ item.time + ' · ' + item.content }}
<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"> {{ item.category }}</div>
......@@ -113,6 +115,17 @@
</template>
<script setup>
import { ref, toRefs, watch, computed } from "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: {
......@@ -154,6 +167,10 @@ const props = defineProps({
currentPage: {
type: Number,
default: 1
},
searchKeyword: {
type: String,
default: ""
}
});
......@@ -180,69 +197,96 @@ function parseChineseDate(dateStr) {
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
}
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 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] : [];
selectedResearchHearingIds.value = val?.researchHearingIds ? [...val.researchHearingIds] : [];
selectedResearchIds.value =
val?.researchTypeIds?.length > 0
? [...val.researchTypeIds]
: [RESOURCE_FILTER_ALL_AREA];
selectedResearchTimeIds.value =
val?.researchTimeIds?.length > 0
? [...val.researchTimeIds]
: [RESOURCE_FILTER_ALL_TIME];
selectedResearchHearingIds.value =
val?.researchHearingIds?.length > 0
? [...val.researchHearingIds]
: [RESOURCE_FILTER_ALL_DEPT];
},
{ immediate: true, deep: true }
);
const buildSelectedFiltersPayload = () => ({
researchTypeIds: [...selectedResearchIds.value],
researchTimeIds: [...selectedResearchTimeIds.value],
researchHearingIds: [...selectedResearchHearingIds.value]
researchTypeIds: stripAllAreaForRequest(selectedResearchIds.value),
researchTimeIds: stripAllTimeForRequest(selectedResearchTimeIds.value),
researchHearingIds: stripAllDeptForRequest(selectedResearchHearingIds.value)
});
const researchTypeIds = computed(() => (researchTypeList.value || []).map(item => item.id));
const researchTimeIds = computed(() => (researchTimeList.value || []).map(item => item.id));
const researchHearingIds = computed(() => (researchHearingList.value || []).map(item => item.id));
const handleAreaGroupChange = (val) => {
selectedResearchIds.value = normalizeExclusiveAllOption(val, RESOURCE_FILTER_ALL_AREA);
handleGetThinkDynamicsReport();
};
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);
handleGetThinkDynamicsReport();
};
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];
const handleDeptGroupChange = (val) => {
selectedResearchHearingIds.value = normalizeExclusiveAllOption(val, RESOURCE_FILTER_ALL_DEPT);
handleGetThinkDynamicsReport();
};
const filteredHearingData = computed(() => {
const areaSel = stripAllAreaForRequest(selectedResearchIds.value);
const timeSel = stripAllTimeForRequest(selectedResearchTimeIds.value);
const deptSel = stripAllDeptForRequest(selectedResearchHearingIds.value);
const rangeStart = getDateYearsAgo(selectedYear.value || 1);
return (hearingData.value || []).filter(item => {
const itemDate = parseChineseDate(item.time);
const matchTopRange = itemDate ? itemDate >= rangeStart : true;
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 matchTopRange && matchYear && matchDepartment && matchType;
});
......@@ -466,6 +510,10 @@ const handleToReportDetail = item => {
}
}
:deep(.keyword-highlight) {
background-color: #fff59d;
}
.right {
width: 1224px;
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,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;
......
......@@ -3,7 +3,8 @@
<div class="main-header">
<div class="search-box">
<el-input placeholder="搜索智库动态" v-model="searchReport" style="width: 270px" />
<el-input placeholder="搜索智库动态" v-model="searchReport" style="width: 270px"
@keyup.enter="handleGetThinkDynamicsReport()" />
<div class="icon">
<img src="./images/search-icon.png" alt="" @click="handleGetThinkDynamicsReport()" />
</div>
......@@ -16,31 +17,9 @@
</div>
<div>
<div class="select-box">
<div class="select-box-time">
<el-select v-model="selectedYear" placeholder="近一年以来" style="width: 120px" :teleported="true"
:placement="'bottom-start'" :popper-options="{
modifiers: [
{
name: 'preventOverflow', // 禁用自动翻转逻辑
options: {
mainAxis: false, // 禁用垂直方向的自动调整
altAxis: false, // 禁用水平方向的自动调整
}
},
{
name: 'flip', // 完全禁用翻转功能
enabled: false
}
]
}">
<el-option @click="handleGetThinkDynamicsReport()" v-for="item in yearList" :key="item.value"
:label="item.label" :value="item.value" />
</el-select>
</div>
<div class="select-box-sort">
<el-select v-model="sort" placeholder="倒序" style="width: 120px" :teleported="true"
<el-select v-model="sort" placeholder="发布时间" style="width: 120px" :teleported="true"
:placement="'bottom-start'" :popper-options="{
modifiers: [
{
......@@ -57,10 +36,9 @@
]
}">
<template #prefix>
<img src="./images/image down.png" class="select-prefix-img" alt="" @click.stop="toggleSortAndFetch()"
:key="true" label="正序" :value="true" v-if="!sort" />
<img src="./images/image up.png" class="select-prefix-img" alt="" @click.stop="toggleSortAndFetch()"
:key="true" label="正序" :value="true" v-if="sort" />
<img v-if="sort !== true" src="./images/image down.png" class="select-prefix-img" alt=""
@click.stop="toggleSortAndFetch()" />
<img v-else src="./images/image up.png" class="select-prefix-img" alt="" @click.stop="toggleSortAndFetch()" />
</template>
<el-option @click="handleGetThinkDynamicsReport()" :key="true" label="正序" :value="true" />
<el-option @click="handleGetThinkDynamicsReport()" :key="false" label="倒序" :value="false" />
......@@ -72,7 +50,7 @@
<div v-if="isThinkTankReport">
<ThinkTankReport :research-type-list="researchTypeList" :research-time-list="researchTimeList"
:key="`智库报告-${tabResetKey}`" :selected-filters="selectedFilters" :cur-footer-list="curFooterList" :total="total"
:current-page="currentPage" @update:selected-filters="handleSelectedFiltersUpdate"
:current-page="currentPage" :search-keyword="searchReport" @update:selected-filters="handleSelectedFiltersUpdate"
@filter-change="(payload) => handleGetThinkDynamicsReport(payload)" @page-change="handleCurrentChange"
@report-click="handleToReportDetail" />
</div>
......@@ -80,6 +58,7 @@
<CongressHearing :research-type-list="researchTypeList" :research-time-list="researchTimeList"
:key="`国会听证会-${tabResetKey}`" :research-hearing-list="researchHearingList" :selected-filters="selectedFilters"
:selected-year="selectedYear" :cur-footer-list="curFooterList" :total="total" :current-page="currentPage"
:search-keyword="searchReport"
:hearing-data="hearingData" @update:selected-filters="handleSelectedFiltersUpdate"
@filter-change="(payload) => handleGetThinkDynamicsReport(payload)" @page-change="handleCurrentChange"
@report-click="handleToReportDetail" />
......@@ -87,7 +66,7 @@
<div>
<SurveyForm v-if="isSurveyForm" :research-type-list="researchTypeList" :research-time-list="researchTimeList"
:key="`调查项目-${tabResetKey}`" :selected-filters="selectedFilters" :cur-footer-list="curFooterList" :total="total"
:current-page="currentPage" @update:selected-filters="handleSelectedFiltersUpdate"
:current-page="currentPage" :search-keyword="searchReport" @update:selected-filters="handleSelectedFiltersUpdate"
@filter-change="(payload) => handleGetThinkDynamicsReport(payload)" @page-change="handleCurrentChange"
@report-click="handleToReportDetail" />
</div>
......@@ -113,8 +92,14 @@ import SurveyForm from "./SurveyForm/index.vue"
import {
getThinkDynamicsReportType,
getHylyList,
getThinkDynamicsReport
getThinkTankReport
} from "@/api/thinkTank/overview";
import {
RESOURCE_FILTER_EARLIER,
stripAllTimeForRequest,
isSelectionCoveringAllOptions,
getResourceLibraryReportDateRangeFromTimeSelection
} from "../../utils/resourceLibraryFilters";
import { useRouter } from "vue-router";
import ThinkTankReport from "./ThinkTankReport/index.vue";
import CongressHearing from "./CongressHearing/index.vue";
......@@ -323,7 +308,7 @@ const createDefaultSelectedFilters = () => ({
const resetThinkDynamicsState = () => {
searchReport.value = ''
selectedYear.value = 1
sort.value = false
sort.value = null
author.value = ''
currentPage.value = 1
selectedFilters.value = createDefaultSelectedFilters()
......@@ -375,8 +360,8 @@ const researchTimeList = ref([
name: '2021年',
},
{
id: '更早以前',
name: '更早以前',
id: RESOURCE_FILTER_EARLIER,
name: RESOURCE_FILTER_EARLIER,
}
])
const researchHearingList = ref([
......@@ -492,35 +477,11 @@ const curFooterList = ref([
// }
]);
const yearList = ref([
{
label: "近一年发布",
value: 1
},
{
label: "近两年发布",
value: 2
},
{
label: "近三年发布",
value: 3
}
]);
const selectedYear = ref(1);
const sort = ref(false);
//获取当前时间x年前的日期
function getDateYearsAgo(years) {
// 获取当前日期
const currentDate = new Date();
// 计算指定年数之前的日期
const pastDate = new Date(currentDate.getFullYear() - years, currentDate.getMonth(), currentDate.getDate());
// 格式化日期为 "YYYY-MM-DD" 的形式
const year = pastDate.getFullYear();
const month = String(pastDate.getMonth() + 1).padStart(2, "0"); // 月份从0开始,需要加1
const day = String(pastDate.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
/** null:界面显示「发布时间」占位且默认按发布时间倒序;true 正序;false 倒序(显式选中),与政策追踪一致 */
const sort = ref(null);
//获取智库研究类型
const handleGetThinkDynamicsReportType = async () => {
try {
......@@ -550,9 +511,9 @@ const handleGetHylyList = async () => {
};
const toggleSortAndFetch = async () => {
sort.value = !sort.value
await handleGetThinkDynamicsReport()
}
sort.value = sort.value === true ? false : true;
await handleGetThinkDynamicsReport();
};
const currentPage = ref(1);
// 处理页码改变事件
const handleCurrentChange = page => {
......@@ -569,50 +530,62 @@ function arrayToString(arr) {
}, "");
}
function buildThinkDynamicsYears(selectedYears) {
const allYearIds = researchTimeList.value.map(item => item.id)
if (!selectedYears?.length || selectedYears.length === allYearIds.length) {
return null
}
return arrayToString(selectedYears)
}
// 获取智库动态报告,payload 为子组件筛选变更时传入的当前选中值,避免时序导致拿到旧值
const handleGetThinkDynamicsReport = async (payload) => {
const nextFilters = payload && typeof payload === 'object'
const nextFilters = payload && typeof payload === "object"
? {
researchTypeIds: payload.researchTypeIds ? [...payload.researchTypeIds] : [],
researchTimeIds: payload.researchTimeIds ? [...payload.researchTimeIds] : [],
researchHearingIds: payload.researchHearingIds ? [...payload.researchHearingIds] : []
}
: selectedFilters.value;
if (payload && typeof payload === 'object') {
if (payload && typeof payload === "object") {
selectedFilters.value = nextFilters;
currentPage.value = 1;
}
// 国会听证会走本地数据,与资源库 /thinkTankOverview/report 无关
if (!isThinkTankReport.value && !isSurveyForm.value) {
return;
}
try {
const parmas = {
id: router.currentRoute._value.params.id,
startDate: getDateYearsAgo(selectedYear.value ?? 1),
parmas: {
searchText: searchReport.value,
authorName: author.value ? author.value : null,
sortFun: sort.value ?? true,
currentPage: currentPage.value - 1,
pageSize: 12,
researchTypeIds: arrayToString(nextFilters.researchTypeIds) === '' ? null : arrayToString(nextFilters.researchTypeIds),
years: buildThinkDynamicsYears(nextFilters.researchTimeIds)
}
const strippedTime = stripAllTimeForRequest(nextFilters.researchTimeIds || []);
const allTimeIds = (researchTimeList.value || []).map((x) => x.id);
const { startDate, endDate } = getResourceLibraryReportDateRangeFromTimeSelection(
strippedTime,
allTimeIds
);
const areas = nextFilters.researchTypeIds || [];
const allAreaIds = (researchTypeList.value || []).map((a) => a.id);
const domainIds = isSelectionCoveringAllOptions(areas, allAreaIds)
? ""
: arrayToString(areas);
const keyword = (searchReport.value || "").trim();
const thinkTankId = router.currentRoute._value.params?.id;
const params = {
pageNum: currentPage.value,
pageSize: 12,
sortFun: sort.value === true,
domainIds,
startDate,
endDate
};
if (isSurveyForm.value) {
params.category = "调查项目";
}
if (thinkTankId) {
params.thinkTankId = thinkTankId;
}
if (keyword) {
params.keyword = keyword;
}
const res = await getThinkDynamicsReport(parmas);
const res = await getThinkTankReport(params);
console.log("智库动态报告", res);
if (res.code === 200 && res.data) {
curFooterList.value = res.data.content
total.value = res.data.totalElements
} else if (res.code === 500 && res.message === '未找到对应筛选的报告') {
curFooterList.value = []
total.value = 0
curFooterList.value = res.data.content;
total.value = res.data.totalElements;
} else if (res.code === 500 && res.message === "未找到对应筛选的报告") {
curFooterList.value = [];
total.value = 0;
}
} catch (error) {
console.error("获取智库动态报告error", error);
......
......@@ -201,7 +201,8 @@
<div class="box3-main-right">
<div class="box3-right-item" v-for="(item, index) in box3RightData" :key="index">
<div class="icon" @click="handleClickPerson(item)">
<img :src="item.imageUrl ? item.imageUrl : DefaultIcon1" alt="" />
<img :src="item.imageUrl ? item.imageUrl : DefaultIcon1" alt=""
@error="() => { if (item.imageUrl) item.imageUrl = null; }" />
</div>
<div class="info">
<div class="info-header">
......@@ -223,17 +224,26 @@
<div class="info"><img src="./images/image-exclamation.png"></div>
<div class="text"> 数据来源:美国国会官网,数据时间:2015.1至2025.12</div>
</div>
<div class="middle">
<div class="middle-text">{{ "共" }}{{ personTotal }}{{ "名核心研究人员" }}</div>
<div class="page">
<div class="image" @click="handlePersonPrevPage"><img src="./images/left-page-btn.png" alt="" /></div>
<div class="page-text">{{ personPageText }}</div>
<div class="image" @click="handlePersonNextPage"><img src="./images/right-page-btn.png" alt="" /></div>
</div>
</div>
<div class="box3-main-bottom">
<div class="box3-right-item" v-for="(item, index) in box3RightData" :key="index">
<div class="box3-right-item" v-for="(item, index) in personList" :key="index">
<div class="icon" @click="handleClickPerson(item)">
<img :src="item.imageUrl ? item.imageUrl : DefaultIcon1" alt="" />
<img :src="item.avatarUrl ? item.avatarUrl : DefaultIcon1" alt=""
@error="() => { if (item.avatarUrl) item.avatarUrl = null; }" />
</div>
<div class="info">
<div class="info-header">
<div class="name">{{ item.name }}</div>
<div class="position">{{ item.positionTitle ?? '全球研究人才' }}</div>
</div>
<div class="info-footer">{{ item.describe }}</div>
<div class="info-footer">{{ item.introduction }}</div>
</div>
</div>
</div>
......@@ -245,14 +255,14 @@
</template>
<script setup>
import { ref, onMounted, nextTick } from "vue";
import { ref, onMounted, nextTick, computed } from "vue";
import setChart from "@/utils/setChart";
import getPieChart from "./utils/piechart";
import getTreeMapChart from "./utils/treeMapChart";
import {
getThinkTankInfoBasic,
getThinkTankInfoBranch,
getThinkTankFundsTotal,
getThinkTankFundsSource,
getThinkTankResearchAreae,
getThinkTankPerson
......@@ -262,7 +272,7 @@ import { useRouter } from "vue-router";
import DefaultIcon1 from '@/assets/icons/default-icon1.png'
import { getPersonSummaryInfo } from "@/api/common/index";
const totalPersonNumber = ref(0)
const router = useRouter();
import InfoImg from "./images/img.png";
import Icon1 from "./images/icon1.png";
......@@ -341,9 +351,9 @@ const handleGetThinkTankInfoBranch = async () => {
// 经费来源
const box1LeftData = ref({
total: "4.358亿美元",
gov: "3.271亿美元",
other: "1.087亿美元"
totalJe: 0,
zfJe: 0,
otherJ: 0
});
const box1ChartData = ref([
......@@ -387,10 +397,19 @@ const box1ChartData = ref([
const handleGetThinkTankFundsTotal = async () => {
try {
const res = await getThinkTankFundsTotal(router.currentRoute._value.params.id);
const res = await getThinkTankFundsSource(router.currentRoute._value.params.id);
console.log("获取经费来源统计", res);
if (res.code === 200 && res.data) {
box1LeftData.value = res.data
// 后端字段已更新:totalAmount/usGovernmentAmount/pentagonContractorAmount
// 前端卡片仍使用 totalJe/zfJe/otherJ 字段名,因此做兼容映射(不改样式)
const totalJe = Number(res.data.totalAmount ?? res.data.totalJe ?? 0);
const zfJe = Number(res.data.usGovernmentAmount ?? res.data.zfJe ?? 0);
const otherJ = Number(res.data.pentagonContractorAmount ?? res.data.otherJ ?? 0);
return box1LeftData.value = {
totalJe: totalJe || 0, // 为空就用 0
zfJe: zfJe || 0, // 为空就用 0
otherJ: otherJ || 0 // 为空就用 0
};
}
} catch (error) {
console.error("获取经费来源统计error", error);
......@@ -404,10 +423,11 @@ const handleGetThinkTankFundsSource = async () => {
console.log("获取经费来源", res);
if (res.code === 200 && res.data) {
const topList = Array.isArray(res.data) ? res.data.slice(0, 7) : []
// 后端字段已改为 sources;机构名优先 institution,其次 secondInstitution
const topList = Array.isArray(res.data.sources) ? res.data.sources : []
const data = topList.map(item => ({
name: item.institution,
value: item.amount,
name: item.institution || item.secondInstitution || "未知机构",
value: Number(item.amount) || 0,
}))
box1ChartData.value = data
const box1Chart = getPieChart(box1ChartData.value);
......@@ -423,24 +443,30 @@ const handleGetThinkTankFundsSource = async () => {
const LABEL_OFFSET_UP = 3 + LABEL_BOX_HEIGHT / 2
const dataList = box1ChartData.value
const total = dataList.reduce((s, d) => s + (d.value || 0), 0) || 1
const formatDeptName = (name) => {
const text = String(name || "");
const chars = Array.from(text);
return chars.length > 9 ? `${chars.slice(0, 9).join("")}...` : text;
};
box1Chart.series[0].label = {
...box1Chart.series[0].label,
alignTo: 'edge',
offset: [0, -LABEL_OFFSET_UP],
formatter(params) {
const valueYi = (params.data.value || 0) / 1000000
const percent = Math.round(params.percent || 0)
const valueStr = `${Math.round(valueYi)}亿 ${percent}%`
const valueYi = (params.data.value || 0) / 100000000
const percent = params.percent || 0
const valueStr = `${valueYi.toFixed(3)}亿 ${percent}%`
let cumulative = 0
for (let i = 0; i < params.dataIndex; i++) cumulative += dataList[i].value || 0
const centerAngle = 90 + ((cumulative + (params.data.value || 0) / 2) / total) * 360
const angleNorm = ((centerAngle % 360) + 360) % 360
// 左侧仅 270–360° 及跨圈:部门左、经费右;其余(含 180–270° 右下两条):经费左、部门右
const isLeftLabel = (angleNorm >= 270 && angleNorm < 360) || centerAngle >= 360
const deptName = formatDeptName(params.data.name);
if (isLeftLabel) {
return `{nameL|${params.data.name}}{valueR|${valueStr}}`
return `{nameL|${deptName}}{valueR|${valueStr}}`
}
return `{valueL|${valueStr}}{nameR|${params.data.name}}`
return `{valueL|${valueStr}}{nameR|${deptName}}`
},
lineHeight: LABEL_BOX_HEIGHT,
rich: {
......@@ -604,8 +630,8 @@ const handleBox3Chart = () => {
delete box3Chart.series[0].itemStyle;
box3Chart.series[0].sort = false;
// 图表充满 box3-main-top,左右各留 12px
box3Chart.series[0].left = 12;
box3Chart.series[0].right = 12;
box3Chart.series[0].left = 0;
box3Chart.series[0].right = 0;
box3Chart.series[0].top = 0;
box3Chart.series[0].bottom = 0;
// 方块内文字与区域标题统一为黑色,方块内文字加 1px 白色描边(等效 CSS text-stroke: 1px #fff)
......@@ -621,57 +647,62 @@ const handleBox3Chart = () => {
};
box3Chart.series[0].upperLabel = {
...box3Chart.series[0].upperLabel,
color: 'rgb(51,51,51)'
color: 'rgb(51,51,51)',
fontSize: 16,
fontWeight: 700,
backgroundColor: 'rgb(247,248,249)',
lineHeight: 16,
height: 16,
padding: 0
};
setChart(box3Chart, "box3Chart");
};
const box3RightData = ref([
{
img: Icon1,
name: "杰森·马西尼 ​",
postion: "兰德公司总裁兼首席执行官",
desc: "曾任职于哈佛大学经济学院、美国财政部"
},
{
img: Icon2,
name: "安德鲁·R·霍恩 ​",
postion: "高级副总裁,研究与分析",
desc: "曾任职于白宫科技政策顾问办公室"
},
{
img: Icon3,
name: "杰森·马西尼 ​",
postion: "兰德公司总裁兼首席执行官",
desc: "日籍研究院,毕业于斯坦福大学"
},
{
img: Icon4,
name: "梅丽莎·罗 ​",
postion: "副总裁,全球研究人才",
desc: "曾任职于美国国防部"
},
{
img: Icon5,
name: "安妮塔·钱德拉 ​ ​",
postion: "副总裁兼主任,兰德社会和经济福祉",
desc: "曾任职于哈佛大学经济学院、美国商务部"
}
]);
const PERSON_PAGE_SIZE = 10;
const personCurrentPage = ref(1);
const box3RightData = ref({
content: [],
totalElements: 0
});
const personList = computed(() => (Array.isArray(box3RightData.value?.content) ? box3RightData.value.content : []));
const personTotal = computed(() => Number(box3RightData.value?.totalElements || 0));
const personTotalPages = computed(() => (personTotal.value > 0 ? Math.ceil(personTotal.value / PERSON_PAGE_SIZE) : 1));
const personPageText = computed(() => `${personCurrentPage.value}/${personTotalPages.value}`);
const handleGetThinkPerson = async () => {
const handleGetThinkPerson = async (page = personCurrentPage.value) => {
try {
const safePage = Math.max(1, Number(page) || 1);
const params = {
currentPage: safePage,
pageSize: PERSON_PAGE_SIZE,
thinkTankId: router.currentRoute._value.params.id
}
const res = await getThinkTankPerson(params);
const res = await getThinkTankPerson(router.currentRoute._value.params.id);
console.log("核心研究人员", res);
if (res.code === 200 && res.data) {
box3RightData.value = res.data
box3RightData.value = {
content: Array.isArray(res.data.content) ? res.data.content : [],
totalElements: Number(res.data.totalElements || 0)
}
// 以后端返回页码为准;未返回时使用请求页码
personCurrentPage.value = Number(res.data.currentPage || res.data.pageNum || safePage);
}
} catch (error) {
console.error("获取核心研究人员error", error);
}
};
const handlePersonPrevPage = () => {
if (personCurrentPage.value <= 1) return;
handleGetThinkPerson(personCurrentPage.value - 1);
};
const handlePersonNextPage = () => {
if (personCurrentPage.value >= personTotalPages.value) return;
handleGetThinkPerson(personCurrentPage.value + 1);
};
// 点击人物头像,跳转到人物主页
const handleClickPerson = async item => {
......@@ -1238,10 +1269,11 @@ onMounted(() => {
.box3-main-top {
width: 1100px;
height: 372px;
height: 379px;
box-sizing: border-box;
padding-left: 35px;
padding-top: 7px;
#box3Chart {
width: 1035px;
......@@ -1287,15 +1319,67 @@ onMounted(() => {
}
}
.middle {
width: 1027px;
height: 32px;
margin-left: 43px;
display: flex;
justify-content: space-between;
margin-top: 24px;
.middle-text {
font-family: "Source Han Sans CN";
font-weight: 400;
font-size: 16px;
line-height: 24px;
letter-spacing: 0px;
text-align: left;
color: rgb(95, 101, 108);
}
.page {
height: 24px;
display: flex;
margin-top: 4px;
.page-text {
font-family: "Source Han Sans CN";
font-weight: 400;
font-size: 14px;
line-height: 22px;
letter-spacing: 0px;
text-align: center;
color: rgb(5, 95, 194);
margin: 1px 8px;
}
.image {
width: 24px;
height: 24px;
img {
width: 100%;
height: 100%;
display: block;
}
}
}
}
.box3-main-bottom {
margin-left: 28px;
margin-left: 35px;
margin-top: 24px;
width: 536px;
height: 326px;
width: 1044px;
height: 352px;
display: flex;
flex-wrap: wrap;
column-gap: 44px;
row-gap: 8px;
align-content: flex-start;
.box3-right-item {
display: flex;
width: 520px;
width: 500px;
height: 64px;
align-items: center;
......@@ -1314,7 +1398,7 @@ onMounted(() => {
}
.info {
width: 459px;
width: calc(100% - 61px);
margin-left: 13px;
.info-header {
......@@ -1322,6 +1406,7 @@ onMounted(() => {
justify-content: space-between;
.name {
width: 219px;
height: 24px;
color: rgba(59, 65, 75, 1);
font-family: "Source Han Sans CN";
......@@ -1329,9 +1414,13 @@ onMounted(() => {
font-weight: 700;
line-height: 24px;
letter-spacing: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.position {
width: 219px;
height: 22px;
color: var(--color-main-active);
font-family: "Source Han Sans CN";
......@@ -1340,6 +1429,9 @@ onMounted(() => {
line-height: 22px;
letter-spacing: 0px;
text-align: right;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
......
......@@ -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>
......
......@@ -197,15 +197,7 @@
</template>
<template v-else>
<div class="box5-chart-row">
<button v-if="box5LegendShowNav" type="button" class="box5-legend-nav-btn"
:disabled="!box5LegendCanPrev" aria-label="上一页图例" @click="handleBox5LegendPrev">
</button>
<div id="box5Chart" class="box5-chart-canvas"></div>
<button v-if="box5LegendShowNav" type="button" class="box5-legend-nav-btn"
:disabled="!box5LegendCanNext" aria-label="下一页图例" @click="handleBox5LegendNext">
</button>
</div>
<div class="source">
<TipTab :text="'智库报告数量变化趋势,数据来源:美国各智库官网'" />
......@@ -342,17 +334,19 @@
</div>
</div>
<div class="select-box">
<div class="paixu-btn" @click="handleSwithSort()">
<div class="icon1">
<img v-if="activeSort" src="@/assets/icons/shengxu1.png" alt="" />
<img v-else src="@/assets/icons/jiangxu1.png" alt="" />
</div>
<div class="text">{{ "发布时间" }}</div>
<div class="icon2">
<img v-if="activeSort" src="@/assets/icons/shengxu2.png" alt="" />
<img v-else src="@/assets/icons/jiangxu2.png" alt="" />
</div>
</div>
<el-select v-model="resourceLibrarySortModel" class="resource-library-sort-select" placeholder="发布时间"
style="width: 120px" :teleported="true" placement="bottom-start"
:popper-options="resourceLibrarySortPopperOptions" @change="handleResourceLibrarySortChange">
<template #prefix>
<img v-if="resourceLibrarySortModel !== true"
src="@/views/thinkTank/ThinkTankDetail/thinkDynamics/images/image down.png"
class="resource-library-sort-prefix-img" alt="" @click.stop="toggleResourceLibrarySortPrefix" />
<img v-else src="@/views/thinkTank/ThinkTankDetail/thinkDynamics/images/image up.png"
class="resource-library-sort-prefix-img" alt="" @click.stop="toggleResourceLibrarySortPrefix" />
</template>
<el-option :key="'resource-lib-sort-asc'" label="正序" :value="true" />
<el-option :key="'resource-lib-sort-desc'" label="倒序" :value="false" />
</el-select>
</div>
<!-- <el-select v-model="sort" placeholder="发布时间" style="width: 120px; margin-left: 8px">
<el-option @click="handleGetetThinkTankReport()" :key="true" label="正序" :value="true" />
......@@ -360,25 +354,17 @@
</el-select> -->
</div>
<div class="resource-library-content">
<HomeMainFooterMain v-if="activeCate === '智库报告'" v-model:checkAll="checkAll"
:is-indeterminate="isIndeterminate" :area-list="areaList" v-model:selectedAreaList="selectedAreaList"
@check-all-change="handleCheckAllChange" @checked-area-change="handleCheckedAreaChange()"
v-model:checkAllTime="checkAllTime" :is-indeterminate-time="isIndeterminateTime"
:pub-time-list="pubTimeList" v-model:selectedPubTimeList="selectedPubTimeList"
@check-all-time-change="handleCheckAllChangeTime" @checked-area-time-change="handleCheckedAreaChangeTime()"
<HomeMainFooterMain v-if="activeCate === '智库报告'" :area-list="areaList"
v-model:selectedAreaList="selectedAreaList" :pub-time-list="pubTimeList"
v-model:selectedPubTimeList="selectedPubTimeList" @filter-change="handleThinkTankReportFilterChange"
:cur-footer-list="curFooterList" :total="total" :current-page="currentPage"
@report-click="handleToReportDetail" @page-change="handleCurrentChange" />
<HomeMainFooterSurvey v-else-if="activeCate === '调查项目'" v-model:checkAll="surveyCheckAll"
:is-indeterminate="surveyIsIndeterminate" :area-list="areaList"
v-model:selectedAreaList="surveySelectedAreaList" @check-all-change="handleSurveyCheckAllChange"
@checked-area-change="handleSurveyCheckedAreaChange()" v-model:checkAllTime="surveyCheckAllTime"
:is-indeterminate-time="surveyIsIndeterminateTime" :pub-time-list="pubTimeList"
v-model:selectedPubTimeList="surveySelectedPubTimeList"
@check-all-time-change="handleSurveyCheckAllChangeTime"
@checked-area-time-change="handleSurveyCheckedAreaChangeTime()" :cur-footer-list="surveyFooterList"
:total="surveyTotal" :current-page="surveyCurrentPage" @report-click="handleToReportDetail"
@page-change="handleSurveyCurrentChange" />
<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"
@report-click="handleToReportDetail" @page-change="handleSurveyCurrentChange" />
<ThinkTankCongressHearingOverview v-else-if="activeCate === '国会听证会'" :key="`congress-${resourceTabResetKey}`"
:research-type-list="areaList" :research-time-list="pubTimeList" @report-click="handleToReportDetail" />
......@@ -423,7 +409,7 @@ import {
getThinkTankReportRemarks
} from "@/api/thinkTank/overview";
import { getPersonSummaryInfo } from "@/api/common/index";
import getMultiLineChart, { MULTILINE_LEGEND_SHOW_COUNT } from "./utils/multiLineChart";
import getMultiLineChart from "./utils/multiLineChart";
import getPieChart from "./utils/piechart";
import getSankeyChart from "./utils/sankey";
import { getChartAnalysis } from "@/api/aiAnalysis/index";
......@@ -437,6 +423,16 @@ import News5 from "./assets/images/news5.png";
import AiButton from '@/components/base/Ai/AiButton/index.vue'
import AiPane from '@/components/base/Ai/AiPane/index.vue'
import TipTab from "./TipTab/index.vue";
import {
RESOURCE_FILTER_ALL_AREA,
RESOURCE_FILTER_ALL_TIME,
RESOURCE_FILTER_EARLIER,
stripAllAreaForRequest,
stripAllTimeForRequest,
buildNumericYearsQueryString,
isSelectionCoveringAllOptions,
getResourceLibraryReportDateRangeFromTimeSelection
} from "./utils/resourceLibraryFilters";
import Message1 from "./assets/images/message-icon1.png";
import Message2 from "./assets/images/message-icon2.png";
......@@ -1009,63 +1005,17 @@ const box5ChartDisplayData = computed(() => {
};
});
const box5LegendPageIndex = ref(0);
const box5LegendPageCount = computed(() => {
const n = box5ChartDisplayData.value?.data?.length ?? 0;
return Math.max(1, Math.ceil(n / MULTILINE_LEGEND_SHOW_COUNT));
});
const box5LegendShowNav = computed(() => {
const n = box5ChartDisplayData.value?.data?.length ?? 0;
return n > MULTILINE_LEGEND_SHOW_COUNT;
});
const box5LegendCanPrev = computed(() => box5LegendPageIndex.value > 0);
const box5LegendCanNext = computed(
() => box5LegendPageIndex.value < box5LegendPageCount.value - 1
);
const handleBox5LegendPrev = () => {
if (box5LegendPageIndex.value <= 0) {
return;
}
box5LegendPageIndex.value -= 1;
renderBox5Chart();
};
const handleBox5LegendNext = () => {
if (box5LegendPageIndex.value >= box5LegendPageCount.value - 1) {
return;
}
box5LegendPageIndex.value += 1;
renderBox5Chart();
};
const renderBox5Chart = () => {
const chartInput = box5ChartDisplayData.value;
if (!chartInput.title.length || !chartInput.data.length) {
return;
}
const totalNames = chartInput.data.length;
const pages = Math.max(1, Math.ceil(totalNames / MULTILINE_LEGEND_SHOW_COUNT));
if (box5LegendPageIndex.value >= pages) {
box5LegendPageIndex.value = pages - 1;
}
if (box5LegendPageIndex.value < 0) {
box5LegendPageIndex.value = 0;
}
const box5Chart = getMultiLineChart(chartInput, {
legendShowCount: MULTILINE_LEGEND_SHOW_COUNT,
legendPageIndex: box5LegendPageIndex.value
});
const box5Chart = getMultiLineChart(chartInput);
setChart(box5Chart, "box5Chart");
};
const handleBox5AreaChange = () => {
aiContentBox5.value = "";
box5LegendPageIndex.value = 0;
renderBox5Chart();
};
......@@ -1073,12 +1023,34 @@ const handleBox5 = async year => {
const y = year != null ? Number(year) : box5selectetedYear.value;
box5selectetedYear.value = y;
box5selectetedArea.value = "全部领域";
box5LegendPageIndex.value = 0;
await handleGetThinkTankPolicyIndustryChange(getBox5YearDateRange(y));
renderBox5Chart();
aiContentBox5.value = "";
};
/** 兼容 getChartAnalysis 返回对象:从 data[0] 提取「解读」文本 */
const getInterpretationTextFromChartResponse = (res) => {
const list = res?.data;
const first = Array.isArray(list) ? list[0] : null;
return (
first?.["解读"] ||
first?.["interpretation"] ||
first?.["analysis"] ||
first?.["content"] ||
""
);
};
/** 参考科技法案数量变化趋势:按 chunk 增量拼接展示 */
const appendAiInterpretationChunk = (targetRef, chunk, loadingText = "解读生成中…") => {
if (!chunk) {
return;
}
const current = String(targetRef.value || "");
const base = current === loadingText ? "" : current;
targetRef.value = base + String(chunk);
};
/** 请求 box5 折线图解读并更新 aiContentBox5(支持流式 SSE) */
const fetchBox5ChartInterpretation = async () => {
const v = box5ChartDisplayData.value;
......@@ -1108,17 +1080,16 @@ const fetchBox5ChartInterpretation = async () => {
})
};
try {
const text = await getChartAnalysis(
const res = await getChartAnalysis(
{ text: JSON.stringify(chartPayload) },
{
onInterpretationDelta: (chunk) => {
if (chunk) {
aiContentBox5.value = chunk;
}
onChunk: chunk => {
appendAiInterpretationChunk(aiContentBox5, chunk);
}
}
);
aiContentBox5.value = text || "未返回有效解读内容";
const text = getInterpretationTextFromChartResponse(res);
aiContentBox5.value = text || aiContentBox5.value || "未返回有效解读内容";
} catch (error) {
console.error("图表解读请求失败", error);
aiContentBox5.value = "解读加载失败";
......@@ -1247,17 +1218,16 @@ const fetchBox6ChartInterpretation = async () => {
}))
};
try {
const text = await getChartAnalysis(
const res = await getChartAnalysis(
{ text: JSON.stringify(chartPayload) },
{
onInterpretationDelta: (chunk) => {
if (chunk) {
aiContentBox6.value = chunk;
}
onChunk: chunk => {
appendAiInterpretationChunk(aiContentBox6, chunk);
}
}
);
aiContentBox6.value = text || "未返回有效解读内容";
const text = getInterpretationTextFromChartResponse(res);
aiContentBox6.value = text || aiContentBox6.value || "未返回有效解读内容";
} catch (error) {
console.error("领域分布图表解读请求失败", error);
aiContentBox6.value = "解读加载失败";
......@@ -1473,17 +1443,16 @@ const fetchBox7ChartInterpretation = async () => {
}))
};
try {
const text = await getChartAnalysis(
const res = await getChartAnalysis(
{ text: JSON.stringify(chartPayload) },
{
onInterpretationDelta: (chunk) => {
if (chunk) {
aiContentBox7.value = chunk;
}
onChunk: chunk => {
appendAiInterpretationChunk(aiContentBox7, chunk);
}
}
);
aiContentBox7.value = text || "未返回有效解读内容";
const text = getInterpretationTextFromChartResponse(res);
aiContentBox7.value = text || aiContentBox7.value || "未返回有效解读内容";
} catch (error) {
console.error("智库资金流向图表解读请求失败", error);
aiContentBox7.value = "解读加载失败";
......@@ -1578,46 +1547,37 @@ const resourceTabResetKey = ref(0)
const resetResourceTabCommon = () => {
// 统一回到“刷新后刚加载出来的状态”
// 分页归 1,排序归默认(false)
// 分页归 1,排序占位「发布时间」且默认倒序(null),与智库动态一致
currentPage.value = 1
sort.value = false
sort.value = null
surveyCurrentPage.value = 1
surveySort.value = false
surveySort.value = null
policyCurrentPage.value = 1
policySort.value = false
policySort.value = null
congressResourceSort.value = null
// 强制重置那些在子组件内部维护状态的组件(国会听证会/政策建议筛选)
resourceTabResetKey.value += 1
}
const resetThinkTankReportFilters = () => {
checkAll.value = false
isIndeterminate.value = true
selectedAreaList.value = []
checkAllTime.value = false
isIndeterminateTime.value = true
selectedPubTimeList.value = [""]
}
selectedAreaList.value = [RESOURCE_FILTER_ALL_AREA];
selectedPubTimeList.value = [RESOURCE_FILTER_ALL_TIME];
};
const resetSurveyFilters = () => {
surveyCheckAll.value = false
surveyIsIndeterminate.value = true
surveySelectedAreaList.value = []
surveyCheckAllTime.value = false
surveyIsIndeterminateTime.value = true
surveySelectedPubTimeList.value = [""]
}
surveySelectedAreaList.value = [RESOURCE_FILTER_ALL_AREA];
surveySelectedPubTimeList.value = [RESOURCE_FILTER_ALL_TIME];
};
const resetPolicyFilters = () => {
// 默认“不过滤领域”:domainIds 传 null,由后端返回全部领域
policySelectedTypeIds.value = []
// 默认选择最新一年,保证 /thinkTankOverview/policy 能返回数据(后端通常需要 years)
policySelectedYearIds.value = pubTimeList.value?.length ? [pubTimeList.value[0].id] : []
}
// 与资源库勾选一致:子组件 remount 后为「全部领域 + 全部时间」,此处用空数组表示不按具体 id 过滤(接口侧走全量/默认年)
policySelectedTypeIds.value = [];
policySelectedYearIds.value = [];
};
const handleClickCate = cate => {
activeCate.value = cate;
......@@ -1672,30 +1632,11 @@ const handleToSocialDetail = item => {
});
window.open(route.href, "_blank");
};
const checkAll = ref(false);
const isIndeterminate = ref(true);
const selectedAreaList = ref([]);
const selectedAreaList = ref([RESOURCE_FILTER_ALL_AREA]);
const handleCheckAllChange = val => {
// console.log(val, "handleCheckAllChange");
if (val) {
isIndeterminate.value = false;
selectedAreaList.value.length !== areaList.value.length
? (selectedAreaList.value = areaList.value.map(obj => obj.id))
: "";
} else {
selectedAreaList.value = [];
}
// selectedAreaList.value = val ? areaList : []
// isIndeterminate.value = false
handleGetetThinkTankReport();
};
const handleCheckedAreaChange = () => {
// console.log(selectedAreaList.value, "handleCheckedAreaChange");
console.log(selectedAreaList.value, "当前选中的领域");
selectedAreaList.value.length !== areaList.value.length
? (isIndeterminate.value = true)
: ((checkAll.value = true), (isIndeterminate.value = false));
/** 资源库-智库报告:筛选变更(与政策追踪相同的「全部」互斥后)重置页码并请求 */
const handleThinkTankReportFilterChange = () => {
currentPage.value = 1;
handleGetetThinkTankReport();
};
......@@ -1719,58 +1660,77 @@ const pubTimeList = ref([
{
id: 2021,
name: "2021"
},
{
id: RESOURCE_FILTER_EARLIER,
name: RESOURCE_FILTER_EARLIER
}
// {
// id: "更早时间",
// name: "更早时间"
// }
]);
const selectedPubTimeList = ref([""]);
const checkAllTime = ref(false);
const isIndeterminateTime = ref(true);
const sort = ref(false);
const activeSort = computed(() => {
if (activeCate.value === "调查项目") return surveySort.value;
if (activeCate.value === "政策建议") return policySort.value;
// 国会听证会目前不走排序;默认使用智库报告排序状态
return sort.value;
const selectedPubTimeList = ref([RESOURCE_FILTER_ALL_TIME]);
/** null:占位「发布时间」且默认倒序;true 正序;false 倒序(显式),与智库动态一致 */
const sort = ref(null);
/** 国会听证会无列表接口排序,仅保持与动态相同的排序控件展示与交互 */
const congressResourceSort = ref(null);
const resourceLibrarySortPopperOptions = {
modifiers: [
{ name: "preventOverflow", options: { mainAxis: false, altAxis: false } },
{ name: "flip", enabled: false }
]
};
const resourceLibrarySortModel = computed({
get() {
if (activeCate.value === "调查项目") {
return surveySort.value;
}
if (activeCate.value === "政策建议") {
return policySort.value;
}
if (activeCate.value === "国会听证会") {
return congressResourceSort.value;
}
return sort.value;
},
set(v) {
if (activeCate.value === "调查项目") {
surveySort.value = v;
} else if (activeCate.value === "政策建议") {
policySort.value = v;
} else if (activeCate.value === "国会听证会") {
congressResourceSort.value = v;
} else {
sort.value = v;
}
}
});
const handleSwithSort = () => {
const handleResourceLibrarySortChange = () => {
if (activeCate.value === "调查项目") {
surveySort.value = !surveySort.value;
handleGetThinkTankSurvey();
return;
}
if (activeCate.value === "政策建议") {
policySort.value = !policySort.value;
} else if (activeCate.value === "政策建议") {
handleGetThinkTankPolicyAdvice();
} else if (activeCate.value === "国会听证会") {
return;
} else {
handleGetetThinkTankReport();
}
sort.value = !sort.value;
handleGetetThinkTankReport();
};
const handleCheckAllChangeTime = val => {
// console.log(val, "handleCheckAllChange");
if (val) {
isIndeterminateTime.value = false;
selectedPubTimeList.value.length !== pubTimeList.value.length
? (selectedPubTimeList.value = pubTimeList.value.map(obj => obj.id))
: "";
const toggleResourceLibrarySortPrefix = () => {
if (activeCate.value === "调查项目") {
surveySort.value = surveySort.value === true ? false : true;
handleGetThinkTankSurvey();
} else if (activeCate.value === "政策建议") {
policySort.value = policySort.value === true ? false : true;
handleGetThinkTankPolicyAdvice();
} else if (activeCate.value === "国会听证会") {
congressResourceSort.value = congressResourceSort.value === true ? false : true;
} else {
selectedPubTimeList.value = [];
sort.value = sort.value === true ? false : true;
handleGetetThinkTankReport();
}
// selectedAreaList.value = val ? areaList : []
// isIndeterminate.value = false
handleGetetThinkTankReport();
};
const handleCheckedAreaChangeTime = () => {
// console.log(selectedAreaList.value, "handleCheckedAreaChange");
console.log(selectedPubTimeList.value, "当前选中的时间");
selectedPubTimeList.value.length !== pubTimeList.value.length
? (isIndeterminateTime.value = true)
: ((checkAllTime.value = true), (isIndeterminateTime.value = false));
handleGetetThinkTankReport();
};
const curFooterList = ref([
......@@ -1851,52 +1811,15 @@ const currentPage = ref(1);
const total = ref(0);
// ===== 调查项目:独立状态(不影响智库报告)=====
const surveyCheckAll = ref(false);
const surveyIsIndeterminate = ref(true);
const surveySelectedAreaList = ref([]);
const surveySelectedPubTimeList = ref([""]);
const surveyCheckAllTime = ref(false);
const surveyIsIndeterminateTime = ref(true);
const surveySort = ref(false);
const surveySelectedAreaList = ref([RESOURCE_FILTER_ALL_AREA]);
const surveySelectedPubTimeList = ref([RESOURCE_FILTER_ALL_TIME]);
const surveySort = ref(null);
const surveyFooterList = ref([]);
const surveyCurrentPage = ref(1);
const surveyTotal = ref(0);
const handleSurveyCheckAllChange = val => {
if (val) {
surveyIsIndeterminate.value = false;
surveySelectedAreaList.value.length !== areaList.value.length
? (surveySelectedAreaList.value = areaList.value.map(obj => obj.id))
: "";
} else {
surveySelectedAreaList.value = [];
}
handleGetThinkTankSurvey();
};
const handleSurveyCheckedAreaChange = () => {
surveySelectedAreaList.value.length !== areaList.value.length
? (surveyIsIndeterminate.value = true)
: ((surveyCheckAll.value = true), (surveyIsIndeterminate.value = false));
handleGetThinkTankSurvey();
};
const handleSurveyCheckAllChangeTime = val => {
if (val) {
surveyIsIndeterminateTime.value = false;
surveySelectedPubTimeList.value.length !== pubTimeList.value.length
? (surveySelectedPubTimeList.value = pubTimeList.value.map(obj => obj.id))
: "";
} else {
surveySelectedPubTimeList.value = [];
}
handleGetThinkTankSurvey();
};
const handleSurveyCheckedAreaChangeTime = () => {
surveySelectedPubTimeList.value.length !== pubTimeList.value.length
? (surveyIsIndeterminateTime.value = true)
: ((surveyCheckAllTime.value = true), (surveyIsIndeterminateTime.value = false));
const handleSurveyFilterChange = () => {
surveyCurrentPage.value = 1;
handleGetThinkTankSurvey();
};
......@@ -1906,12 +1829,24 @@ const handleSurveyCurrentChange = page => {
};
const handleGetThinkTankSurvey = async () => {
const { startDate, endDate } = getResourceLibraryReportDateRangeFromTimeSelection(
stripAllTimeForRequest(surveySelectedPubTimeList.value),
(pubTimeList.value || []).map((x) => x.id)
);
const params = {
pageNum: surveyCurrentPage.value,
pageSize: 12,
sortFun: surveySort.value,
researchTypeIds: arrayToString(surveySelectedAreaList.value),
years: arrayToString(surveySelectedPubTimeList.value),
sortFun: surveySort.value === true,
domainIds: (() => {
const areas = stripAllAreaForRequest(surveySelectedAreaList.value);
const allAreaIds = (areaList.value || []).map((a) => a.id);
if (isSelectionCoveringAllOptions(areas, allAreaIds)) {
return "";
}
return arrayToString(areas);
})(),
startDate,
endDate,
category: "调查项目"
};
try {
......@@ -1932,12 +1867,14 @@ const handleGetThinkTankSurvey = async () => {
const policyFooterList = ref([]);
const policyCurrentPage = ref(1);
const policyTotal = ref(0);
const policySort = ref(false);
const policySort = ref(null);
const policySelectedTypeIds = ref([]);
const policySelectedYearIds = ref([]);
const handlePolicyFilterChange = payload => {
policySelectedTypeIds.value = payload?.researchTypeIds ? [...payload.researchTypeIds] : [];
// 子组件 ThinkTankPolicyAdviceOverview 传 researchTypeIds(与智库报告筛选一致)
const typeIds = payload?.researchTypeIds ?? payload?.domainIds;
policySelectedTypeIds.value = Array.isArray(typeIds) ? [...typeIds] : [];
policySelectedYearIds.value = payload?.researchTimeIds ? [...payload.researchTimeIds] : [];
policyCurrentPage.value = 1;
handleGetThinkTankPolicyAdvice();
......@@ -1983,21 +1920,32 @@ const handleGetThinkTankPolicyAdvice = async () => {
policyFooterList.value = mock.slice(start, start + pageSize);
};
const fallbackYear = pubTimeList.value?.length ? String(pubTimeList.value[0].id) : null
const yearsStr = arrayToString(policySelectedYearIds.value)
// 领域:如果用户还没选领域,则默认使用“全部领域”的 id 列表
const effectiveDomainIds = policySelectedTypeIds.value.length
? policySelectedTypeIds.value
: (areaList.value || []).map(obj => obj.id)
const domainIdsStr = arrayToString(effectiveDomainIds)
const strippedPolicyYears = stripAllTimeForRequest(policySelectedYearIds.value);
const allPubTimeIds = (pubTimeList.value || []).map((x) => x.id);
/** 与智库报告一致:仅「全部时间」或选满所有具体年份 → 不按 years 狭义过滤 */
const isPolicyAllTime =
strippedPolicyYears.length === 0 ||
(allPubTimeIds.length > 0 && isSelectionCoveringAllOptions(strippedPolicyYears, allPubTimeIds));
const typeIdsForApi = stripAllAreaForRequest(policySelectedTypeIds.value);
// 领域:未选具体领域(仅「全部领域」)时传全量 id 串
const effectiveDomainIds = typeIdsForApi.length
? typeIdsForApi
: (areaList.value || []).map(obj => obj.id);
const domainIdsStr = arrayToString(effectiveDomainIds);
const params = {
currentPage: policyCurrentPage.value - 1,
pageSize: 7,
sortFun: policySort.value,
years: yearsStr === "" ? fallbackYear : yearsStr
sortFun: policySort.value === true
};
// 全部时间:不传 years,避免误用首项年份(如仅查 2025)
if (!isPolicyAllTime) {
const yearsStr = buildNumericYearsQueryString(strippedPolicyYears);
if (yearsStr) {
params.years = yearsStr;
}
}
// 领域:始终传 domainIds;如果用户没选,就传“全部领域”的 id 串
params.domainIds = domainIdsStr || null
params.domainIds = domainIdsStr || null;
try {
const res = await getThinkTankOverviewPolicy(params);
if (res.code === 200 && res.data) {
......@@ -2050,12 +1998,24 @@ function arrayToString(arr) {
//获取智库报告
const handleGetetThinkTankReport = async () => {
const { startDate, endDate } = getResourceLibraryReportDateRangeFromTimeSelection(
stripAllTimeForRequest(selectedPubTimeList.value),
(pubTimeList.value || []).map((x) => x.id)
);
const params = {
pageNum: currentPage.value,
pageSize: 12,
sortFun: sort.value,
researchTypeIds: arrayToString(selectedAreaList.value),
years: arrayToString(selectedPubTimeList.value)
sortFun: sort.value === true,
domainIds: (() => {
const areas = stripAllAreaForRequest(selectedAreaList.value);
const allAreaIds = (areaList.value || []).map((a) => a.id);
if (isSelectionCoveringAllOptions(areas, allAreaIds)) {
return "";
}
return arrayToString(areas);
})(),
startDate,
endDate
};
try {
const res = await getThinkTankReport(params);
......@@ -2505,7 +2465,7 @@ onMounted(async () => {
justify-content: space-between;
gap: 16px;
position: relative;
z-index: 9999999;
overflow: visible;
.card {
......@@ -2519,12 +2479,12 @@ onMounted(async () => {
transition: all 0.3s;
cursor: pointer;
position: relative; // 让 red-info 按当前 card 自身定位
z-index: 9999998;
&:hover {
transform: translateY(-3px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
z-index: 9999999;
z-index: 9;
}
.red-info {
......@@ -2549,7 +2509,7 @@ onMounted(async () => {
padding: 2px 8px;
/* 左右留空隙,更美观 */
white-space: nowrap;
z-index: 10000000;
z-index: 10;
}
.card-header {
......@@ -3602,40 +3562,10 @@ onMounted(async () => {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 8px;
width: 100%;
box-sizing: border-box;
}
.box5-legend-nav-btn {
flex-shrink: 0;
width: 32px;
height: 32px;
margin-top: 6px;
padding: 0;
box-sizing: border-box;
border: 1px solid rgba(220, 223, 230, 1);
border-radius: 6px;
background: rgba(255, 255, 255, 1);
color: rgba(59, 65, 75, 1);
font-size: 20px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.box5-legend-nav-btn:hover:not(:disabled) {
border-color: var(--color-main-primary, #055fc2);
color: var(--color-main-primary, #055fc2);
}
.box5-legend-nav-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.box5-chart-canvas {
flex: 1;
min-width: 0;
......@@ -4220,56 +4150,29 @@ onMounted(async () => {
box-sizing: border-box;
padding: 5px 0;
.paixu-btn {
display: flex;
width: 120px;
.resource-library-sort-select {
height: 32px;
box-sizing: border-box;
border: 1px solid rgba(230, 231, 232, 1);
box-shadow: 0px 0px 20px 0px rgba(94, 95, 95, 0.1);
border: 1px solid rgb(230, 231, 232);
border-radius: 4px;
background: rgba(255, 255, 255, 1);
background: rgb(255, 255, 255);
&:hover {
background: var(--color-bg-hover);
}
cursor: pointer;
.icon1 {
width: 11px;
height: 14px;
margin-top: 10px;
margin-left: 9px;
img {
width: 100%;
height: 100%;
}
.resource-library-sort-prefix-img {
width: 8px;
height: 8px;
margin-right: 4px;
cursor: pointer;
}
.text {
height: 19px;
color: rgba(95, 101, 108, 1);
font-family: Microsoft YaHei;
font-size: 14px;
font-weight: 400;
line-height: 18px;
letter-spacing: 0px;
text-align: left;
margin-top: 7px;
margin-left: 9px;
:deep(.el-select__wrapper) {
min-height: 30px;
box-shadow: none;
}
.icon2 {
width: 10px;
height: 5px;
margin-top: 5px;
margin-left: 13px;
img {
width: 100%;
height: 100%;
}
:deep(.el-select-dropdown) {
left: 0 !important;
top: 100% !important;
transform: none !important;
}
}
}
......
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 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论