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;