From ab20424aa9143d83a87cb8ae17023886504d43c0 Mon Sep 17 00:00:00 2001 From: kx_guo Date: Mon, 7 Sep 2026 13:53:29 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E4=BE=9B=E5=BA=94=E5=95=86?= =?UTF-8?q?=E9=A2=91=E6=AC=A1=E5=8D=A1=E7=89=87=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 展示近3年异常次数和合作频次代理值 - 增加频次口径和无供应商数据提示 - 兼容旧历史风险卡片数据 - 保留历史会话加载超时和流式响应处理优化 --- src/api/conversations.ts | 74 +++++++++++------ .../assistant/AssistantResponseCard.vue | 8 +- src/components/assistant/RiskAnalysisCard.vue | 81 ++++++++++++++----- src/components/layout/AppSidebar.vue | 7 ++ src/types/risk.ts | 5 +- 5 files changed, 126 insertions(+), 49 deletions(-) diff --git a/src/api/conversations.ts b/src/api/conversations.ts index 211bb15..d0e403a 100644 --- a/src/api/conversations.ts +++ b/src/api/conversations.ts @@ -12,6 +12,7 @@ export interface ConversationStreamEvent { export function fetchConversations(limit = 50, search?: string) { return http.get>("/v1/conversations", { params: { limit, search }, + timeout: 30_000, }); } @@ -24,6 +25,7 @@ export function createConversation(title?: string) { export function fetchConversation(id: number, offset = 0) { return http.get>(`/v1/conversations/${id}`, { params: { limit: 20, offset }, + timeout: 60_000, }); } @@ -45,6 +47,8 @@ export async function streamConversationMessage( messageType: "text" | "image" | "file" | "audio" = "text", ) { const token = localStorage.getItem("risk_prediction_token"); + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), 180_000); const response = await fetch( `${import.meta.env.VITE_API_BASE_URL || "/api"}/v1/conversations/${id}/stream`, { @@ -55,6 +59,7 @@ export async function streamConversationMessage( ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify({ content, messageType }), + signal: controller.signal, }, ); if (response.status === 401) { @@ -64,32 +69,51 @@ export async function streamConversationMessage( if (!response.ok || !response.body) throw new Error(`流式请求失败(HTTP ${response.status})`); - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - let completed = false; - while (true) { - const { value, done } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - for (const line of lines) { - if (!line.startsWith("data:")) continue; - const raw = line.slice(5).trim(); - if (!raw) continue; - const event = JSON.parse(raw) as ConversationStreamEvent; - onEvent(event); - if (event.type === "done") completed = true; + try { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let completed = false; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const line of lines) { + if (!line.startsWith("data:")) continue; + const raw = line.slice(5).trim(); + if (!raw) continue; + let event: ConversationStreamEvent; + try { + event = JSON.parse(raw) as ConversationStreamEvent; + } catch { + throw new Error("AI 流式响应格式错误,请重试"); + } + onEvent(event); + if (event.type === "done") completed = true; + } } - } - if (buffer.startsWith("data:")) { - const raw = buffer.slice(5).trim(); - if (raw) { - const event = JSON.parse(raw) as ConversationStreamEvent; - onEvent(event); - if (event.type === "done") completed = true; + if (buffer.startsWith("data:")) { + const raw = buffer.slice(5).trim(); + if (raw) { + let event: ConversationStreamEvent; + try { + event = JSON.parse(raw) as ConversationStreamEvent; + } catch { + throw new Error("AI 流式响应格式错误,请重试"); + } + onEvent(event); + if (event.type === "done") completed = true; + } } + if (!completed) throw new Error("AI 流式连接中断,请重试"); + } catch (reason) { + if (controller.signal.aborted) { + throw new Error("AI 响应超时,请稍后重试", { cause: reason }); + } + throw reason; + } finally { + window.clearTimeout(timeout); } - if (!completed) throw new Error("AI 流式连接中断,请重试"); } diff --git a/src/components/assistant/AssistantResponseCard.vue b/src/components/assistant/AssistantResponseCard.vue index d8f0e92..1a675db 100644 --- a/src/components/assistant/AssistantResponseCard.vue +++ b/src/components/assistant/AssistantResponseCard.vue @@ -24,6 +24,8 @@ interface CardData extends AssistantResponseCard { anomalyCount?: number; cooperationCount?: number | null; frequencyCoefficient?: number; + frequencyBasis?: string; + dataAvailable?: boolean; tier?: string; topProblems?: Array<{ name: string; count: number }>; frequentProblems?: Array<{ name: string; count: number }>; @@ -94,14 +96,14 @@ function formatCount(value: unknown) {
{{ formatCount(data.anomalyCount) }}历史异常 + >近3年异常
{{ formatCount(data.cooperationCount) }}合作次数 + >合作频次(代理)
- ×{{ data.frequencyCoefficient }}×{{ data.frequencyCoefficient?.toFixed(2) ?? "1.00" }}频次系数
diff --git a/src/components/assistant/RiskAnalysisCard.vue b/src/components/assistant/RiskAnalysisCard.vue index a29418b..0f83e1c 100644 --- a/src/components/assistant/RiskAnalysisCard.vue +++ b/src/components/assistant/RiskAnalysisCard.vue @@ -141,26 +141,58 @@ const stars = computed(() => {

供应商合作频次分析

-
- {{ result.supplierFreq.supplierName }} - 异常 {{ result.supplierFreq.anomalyCount ?? 0 }} 次 -
-
- 频次风险系数×{{ result.supplierFreq.alpha.toFixed(2) }} -
-
- 供应商层级{{ result.supplierFreq.tier }} -
-
- 合作次数{{ - result.supplierFreq?.cooperationCount ?? "暂无数据" - }} -
-
- 建议{{ result.supplierFreq.suggestion }} -
+ +

+ {{ + result.supplierFreq?.note || "未指定供应商,暂无供应商频次分析。" + }} +

+ + {{ + result.supplierFreq.note || + "当前以近3年异常记录数作为合作频次代理,不代表真实采购订单合作次数。" + }} +
@@ -507,6 +539,15 @@ h3 { text-align: right; } +.supplier-analysis__empty, +.supplier-analysis__note { + display: block; + margin: 4px 0 0; + color: #6d5a91; + font-size: 10px; + line-height: 1.5; +} + .standards-card { border: 1px solid #bfdbfe; } diff --git a/src/components/layout/AppSidebar.vue b/src/components/layout/AppSidebar.vue index 77f9a05..4cab6ed 100644 --- a/src/components/layout/AppSidebar.vue +++ b/src/components/layout/AppSidebar.vue @@ -55,6 +55,13 @@ function startNewChat() { } async function openConversation(id: number) { + if (conversationStore.current?.id === id) { + await router.push({ + name: "assessment", + query: { conversation: String(id) }, + }); + return; + } try { await conversationStore.open(id); await router.push({ diff --git a/src/types/risk.ts b/src/types/risk.ts index e22e397..d3a0bd6 100644 --- a/src/types/risk.ts +++ b/src/types/risk.ts @@ -60,9 +60,12 @@ export interface RiskAssessmentResult { supplierName: string | null; cooperationCount: number | null; anomalyCount: number | null; - alpha: number; + alpha: number | null; tier: string | null; suggestion: string | null; + frequencyBasis?: string; + note?: string | null; + dataAvailable?: boolean; } | null; standardJudgment?: Array<{ category: string;