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

feat:多智库报告流式显示思考过程功能以及图表ai解读流式显示思考过程功能及样式开发

上级 82ef459c
// 智库概览信息
import request from "@/api/request.js";
import request, { getToken } from "@/api/request.js";
// 智库列表
export function getThinkTankList() {
......@@ -162,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',
......@@ -171,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).trim()
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).trim()
if (c) onReasoningChunk(c)
}
} catch (e) {
// ignore
}
}
}
return fullText
}
//提出建议领域分布
export function getThinkPolicyIndustry(params) {
......
......@@ -160,17 +160,48 @@
</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-line" v-for="(line, idx) in beingAnalysisChunks" :key="idx">
{{ line }}
</div>
</AnalysisBox>
<AnalysisResultBox title="核心观点分析" :showAllBtn="false" v-if="!isBox2" @tab-change="handleOpinionTabChange">
<div v-if="isAnalysisLoading" class="analysis-loading">
正在加载
</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 && !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>
......@@ -189,34 +220,33 @@
<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="" />
<!-- <img src="../assets/images/up.png" alt="" v-else /> -->
<img src="../assets/images/down.png" alt="" v-if="!openConsensusIds.has(item.id)" />
<img src="../assets/images/up.png" alt="" v-else />
</div>
</div>
<!-- <div class="Consensus-expand" v-if="openConsensusIds.has(item.id)">
<div class="Consensus-expand-row">
<div class="Consensus-expand" v-if="openConsensusIds.has(item.id)">
<div class="Consensus-expand-title">
{{ "来源观点列表" }}
</div>
<div class="Consensus-expand-subtitle">
<div class="Consensus-expand-image">
<img src="../assets/images/rand-image.png" alt="" />
<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>
{{ `${item.reportCount}条来源观点` }}
<div class="source-view-detail-content">{{ getSourceViewDisplayContent(sv) }}</div>
</div>
<div class="Consensus-expand-badge is-tech-development">{{ "共识观点" }}</div>
</div>
<div class="Consensus-expand-content">
{{ item.sourceViewText }}
</template>
<div class="empty-image" v-if="consensusList.length === 0">
<img src="../assets/images/empty-analysis.png" alt="" />
</div>
<div class="Consensus-expand-badge is-tech-development">
{{ "共识观点" }}
<div class="empty-text" v-if="consensusList.length === 0">
{{ "未识别到共识观点..." }}
</div>
</div> -->
</template>
</div>
</div>
<div class="box2-main-Differences" v-else-if="!isAnalysisLoading">
......@@ -240,16 +270,22 @@
<div class="Differences-expand-content">
<div class="Differences-expand-top-content">
<div class="Differences-expand-top-content-left">
<div class="content-left-title">{{ "来源观点列表" }}</div>
<div class="content-left-img-name">
<div class="content-left-img">
<img src="../assets/images/rand-image.png" alt="" />
<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="content-left-name">
{{ `${item.reportCount}条来源观点` }}
<div class="source-view-detail-content">{{ getSourceViewDisplayContent(sv) }}</div>
</div>
</div>
<div class="content-left-text">{{ item.sourceViewText }}</div>
</div>
</div>
......@@ -261,7 +297,12 @@
</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>
......@@ -271,15 +312,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, getThinkTankReportViewpoint, 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 beingAnalysisChunks = 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(() => beingAnalysisChunks.value.length, async () => {
if (!isBeingAnalysisExpanded.value) return
await scrollBeingAnalysisToBottom()
})
watch(isBeingAnalysisExpanded, async (expanded) => {
if (!expanded) return
await scrollBeingAnalysisToBottom()
})
const handleOpinionTabChange = type => {
activeOpinionTab.value = type
}
......@@ -358,6 +424,33 @@ 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/)
......@@ -398,12 +491,31 @@ const consensusList = computed(() => {
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: sourceViews.length,
sourceViewText
reportCount: sourceViewDetails.length,
sourceViewText,
sourceViewDetails
}
})
})
......@@ -412,12 +524,31 @@ const differenceList = computed(() => {
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: sourceViews.length,
sourceViewText
reportCount: sourceViewDetails.length,
sourceViewText,
sourceViewDetails
}
})
})
......@@ -456,6 +587,8 @@ const handleAnalysis = async () => {
if (!canProceed.value) return
isBox2.value = false
isAnalysisLoading.value = true
isBeingAnalysisExpanded.value = false
beingAnalysisChunks.value = []
domainViewAnalysisRes.value = null
// 每次进入“开始分析”默认回到共识观点,避免上次状态残留导致内容错位
activeOpinionTab.value = 'consensus'
......@@ -464,6 +597,10 @@ const handleAnalysis = async () => {
const handleBack = () => {
isChoseItem.value = true
isBox2.value = true
// 返回选择时终止“分析中”展示,恢复空态图
isAnalysisLoading.value = false
isBeingAnalysisExpanded.value = false
beingAnalysisChunks.value = []
// 返回选择时也重置,确保下次进入分析展示一致
activeOpinionTab.value = 'consensus'
}
......@@ -524,10 +661,41 @@ const normalizeViewList = raw => {
.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 {
viewpointDetailByKey.value = {}
beingAnalysisChunks.value = []
const selectedReports = selectedReportList.value || []
const reportViewListRaw = await Promise.all(
const reportEntriesRaw = await Promise.all(
selectedReports.map(async (report) => {
const reportId = report?.id
if (!reportId) return null
......@@ -535,16 +703,49 @@ const handlePostReportDomainViewAnalysis = async () => {
const viewpointRes = await getThinkTankReportViewpoint({
reportId,
currentPage: 0,
pageSize: 100,
pageSize: 10000,
keyword: "",
orgIds: ""
})
console.log("分析时用到的观点", viewpointRes)
if (viewpointRes?.code !== 200 || !viewpointRes?.data) return null
const views = normalizeViewList(viewpointRes.data.content ?? viewpointRes.data)
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
view_list: views,
detailMap
}
} catch (error) {
console.error(`获取报告核心论点失败: ${reportId}`, error)
......@@ -553,20 +754,62 @@ const handlePostReportDomainViewAnalysis = async () => {
})
)
const reportViewList = reportViewListRaw.filter(Boolean)
if (!reportViewList.length) {
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 payload = {
domain: domainName.value || "全部领域",
report_view_list: reportViewList
// 按报告 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 res = await postReportDomainViewAnalysis(payload)
console.log('智库领域观点分析接口返回', res)
domainViewAnalysisRes.value = res
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) => {
beingAnalysisChunks.value = [...beingAnalysisChunks.value, 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
......@@ -736,16 +979,150 @@ onMounted(async () => {
cursor: pointer;
}
.analysis-loading {
.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%;
min-height: 320px;
display: block;
}
}
}
.being-analysis-detail-box {
width: 1063px;
height: 160px;
background-color: rgb(246, 250, 255);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: rgba(95, 101, 108, 1);
font-size: 16px;
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-line {
line-height: 22px;
white-space: nowrap;
}
.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 {
......@@ -1320,12 +1697,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;
......@@ -1343,7 +1722,7 @@ onMounted(async () => {
width: 100%;
display: flex;
padding-top: 4px;
padding-top: 13px;
padding-left: 24px;
padding-right: 24px;
padding-bottom: 18px;
......@@ -1431,15 +1810,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;
......@@ -1525,12 +1942,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 {
......@@ -1620,6 +2038,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);
}
}
......@@ -1631,7 +2125,7 @@ onMounted(async () => {
width: 100%;
display: flex;
padding-top: 4px;
padding-top: 13px;
padding-left: 24px;
padding-right: 24px;
padding-bottom: 18px;
......@@ -1641,14 +2135,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;
......@@ -1661,6 +2158,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);
}
......@@ -1690,8 +2220,8 @@ onMounted(async () => {
}
.Differences-item-title {
width: 958px;
flex: 1;
min-width: 0;
min-height: 30px;
margin-left: 18px;
......@@ -1703,36 +2233,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;
......@@ -1749,12 +2268,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;
}
......@@ -1775,14 +2295,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;
......@@ -1798,7 +2318,7 @@ onMounted(async () => {
}
.content-left-img-name {
width: 452px;
width: 948px;
height: 22px;
display: flex;
margin-top: 8px;
......@@ -1844,76 +2364,82 @@ onMounted(async () => {
}
.differences-source-views {
.content-right-title {
width: 452px;
height: 24px;
display: flex;
.source-view-detail {
margin-top: 16px;
padding-bottom: 16px;
border-bottom: 1px solid rgb(234, 236, 238);
}
/* 关键:超出宽度显示 ... */
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: rgb(59, 65, 75);
.source-view-detail-title {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
font-family: "Source Han Sans CN";
font-weight: 400 !important;
font-size: 16px;
font-weight: 500;
font-size: 15px;
line-height: 24px;
letter-spacing: 0px;
text-align: justify;
color: rgb(59, 65, 75);
margin-bottom: 6px;
}
.content-right-img-name {
width: 452px;
height: 22px;
display: flex;
margin-top: 8px;
.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-img {
width: 16px;
height: 16px;
margin-top: 3px;
margin-right: 4px;
.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: 100%;
height: 100%;
width: 16px;
height: 16px;
display: block;
}
}
.content-right-name {
object-fit: cover;
margin-top: 3px;
}
height: 22px;
.source-view-detail-org-text {
font-family: "Source Han Sans CN";
font-weight: 400;
/* Regular */
font-size: 14px;
line-height: 22px;
letter-spacing: 0;
letter-spacing: 0px;
text-align: left;
/* 左对齐 */
color: rgb(95, 101, 108);
}
.Differences-expand-top-content-right {
width: 452px;
display: flex;
flex-direction: column;
}
.content-right-text {
.source-view-detail-content {
font-family: "Source Han Sans CN";
font-weight: 400;
font-size: 16px;
line-height: 30px;
letter-spacing: 0px;
letter-spacing: 0;
text-align: justify;
color: rgb(95, 101, 108);
margin-top: 8px;
}
}
.Differences-expand-view {
width: 948px;
height: 36px;
......@@ -1945,28 +2471,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;
}
}
......
......@@ -67,12 +67,21 @@
<div class="box5">
<AnalysisBox title="报告关键词云" :showAllBtn="true">
<div class="box5-main">
<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>
......@@ -167,8 +176,8 @@
</div>
</div>
</div>
<div v-if="isOpinionExpanded(item, index)" class="desc"
v-html="highlightOpinionText(item.contentZh)"></div>
<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">
{{ val }}
......@@ -217,6 +226,9 @@ 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();
......@@ -240,6 +252,17 @@ 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('');
......@@ -578,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();
......@@ -817,6 +901,7 @@ onMounted(() => {
width: 480px;
height: 415px;
.box5-main {
width: 480px;
height: 361px;
......@@ -825,8 +910,10 @@ onMounted(() => {
padding-top: 26px;
padding-bottom: 43px;
display: flex;
flex-direction: column;
box-sizing: border-box;
overflow: hidden;
position: relative;
.box5Chart {
width: 418px;
......@@ -834,6 +921,33 @@ onMounted(() => {
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;
}
}
}
......
......@@ -678,6 +678,16 @@ const getInterpretationTextFromChartResponse = (res) => {
);
};
/** 参考科技法案数量变化趋势:按 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 : [];
......@@ -705,7 +715,14 @@ const fetchPolicyPtBox1ChartInterpretation = async () => {
}))
};
try {
const res = await getChartAnalysis({ text: JSON.stringify(chartPayload) });
const res = await getChartAnalysis(
{ text: JSON.stringify(chartPayload) },
{
onChunk: chunk => {
appendAiInterpretationChunk(aiContentPolicyPt1, chunk);
}
}
);
const text = getInterpretationTextFromChartResponse(res);
aiContentPolicyPt1.value = text || aiContentPolicyPt1.value || "未返回有效解读内容";
} catch (error) {
......@@ -743,7 +760,14 @@ const fetchPolicyPtBox2ChartInterpretation = async () => {
}))
};
try {
const res = await getChartAnalysis({ text: JSON.stringify(chartPayload) });
const res = await getChartAnalysis(
{ text: JSON.stringify(chartPayload) },
{
onChunk: chunk => {
appendAiInterpretationChunk(aiContentPolicyPt2, chunk);
}
}
);
const text = getInterpretationTextFromChartResponse(res);
aiContentPolicyPt2.value = text || aiContentPolicyPt2.value || "未返回有效解读内容";
} catch (error) {
......@@ -789,7 +813,14 @@ const fetchPolicyPtBox3ChartInterpretation = async () => {
})
};
try {
const res = await getChartAnalysis({ text: JSON.stringify(chartPayload) });
const res = await getChartAnalysis(
{ text: JSON.stringify(chartPayload) },
{
onChunk: chunk => {
appendAiInterpretationChunk(aiContentPolicyPt3, chunk);
}
}
);
const text = getInterpretationTextFromChartResponse(res);
aiContentPolicyPt3.value = text || aiContentPolicyPt3.value || "未返回有效解读内容";
} catch (error) {
......
......@@ -1041,6 +1041,16 @@ const getInterpretationTextFromChartResponse = (res) => {
);
};
/** 参考科技法案数量变化趋势:按 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;
......@@ -1070,7 +1080,14 @@ const fetchBox5ChartInterpretation = async () => {
})
};
try {
const res = await getChartAnalysis({ text: JSON.stringify(chartPayload) });
const res = await getChartAnalysis(
{ text: JSON.stringify(chartPayload) },
{
onChunk: chunk => {
appendAiInterpretationChunk(aiContentBox5, chunk);
}
}
);
const text = getInterpretationTextFromChartResponse(res);
aiContentBox5.value = text || aiContentBox5.value || "未返回有效解读内容";
} catch (error) {
......@@ -1201,7 +1218,14 @@ const fetchBox6ChartInterpretation = async () => {
}))
};
try {
const res = await getChartAnalysis({ text: JSON.stringify(chartPayload) });
const res = await getChartAnalysis(
{ text: JSON.stringify(chartPayload) },
{
onChunk: chunk => {
appendAiInterpretationChunk(aiContentBox6, chunk);
}
}
);
const text = getInterpretationTextFromChartResponse(res);
aiContentBox6.value = text || aiContentBox6.value || "未返回有效解读内容";
} catch (error) {
......@@ -1419,7 +1443,14 @@ const fetchBox7ChartInterpretation = async () => {
}))
};
try {
const res = await getChartAnalysis({ text: JSON.stringify(chartPayload) });
const res = await getChartAnalysis(
{ text: JSON.stringify(chartPayload) },
{
onChunk: chunk => {
appendAiInterpretationChunk(aiContentBox7, chunk);
}
}
);
const text = getInterpretationTextFromChartResponse(res);
aiContentBox7.value = text || aiContentBox7.value || "未返回有效解读内容";
} catch (error) {
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论