完善供应商频次卡片展示

- 展示近3年异常次数和合作频次代理值
- 增加频次口径和无供应商数据提示
- 兼容旧历史风险卡片数据
- 保留历史会话加载超时和流式响应处理优化
This commit is contained in:
kx_guo 2026-09-07 13:53:29 +08:00
parent f0a940eb10
commit ab20424aa9
5 changed files with 126 additions and 49 deletions

View File

@ -12,6 +12,7 @@ export interface ConversationStreamEvent {
export function fetchConversations(limit = 50, search?: string) {
return http.get<ApiResponse<ConversationSummary[]>>("/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<ApiResponse<Conversation>>(`/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 流式连接中断,请重试");
}

View File

@ -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) {
<div class="metric-grid">
<div>
<b>{{ formatCount(data.anomalyCount) }}</b
><span>历史异常</span>
><span>近3年异常</span>
</div>
<div>
<b>{{ formatCount(data.cooperationCount) }}</b
><span>合作次数</span>
><span>合作频次(代理)</span>
</div>
<div>
<b>×{{ data.frequencyCoefficient }}</b
<b>×{{ data.frequencyCoefficient?.toFixed(2) ?? "1.00" }}</b
><span>频次系数</span>
</div>
</div>

View File

@ -141,26 +141,58 @@ const stars = computed(() => {
<section class="supplier-analysis">
<h3><Factory :size="14" /> 供应商合作频次分析</h3>
<div v-if="result.supplierFreq?.supplierName">
<span>{{ result.supplierFreq.supplierName }}</span>
<strong>异常 {{ result.supplierFreq.anomalyCount ?? 0 }} 次</strong>
</div>
<div v-if="result.supplierFreq">
<span>频次风险系数</span
><strong>×{{ result.supplierFreq.alpha.toFixed(2) }}</strong>
</div>
<div v-if="result.supplierFreq?.tier">
<span>供应商层级</span><strong>{{ result.supplierFreq.tier }}</strong>
</div>
<div>
<span>合作次数</span
><strong>{{
result.supplierFreq?.cooperationCount ?? "暂无数据"
}}</strong>
</div>
<div v-if="result.supplierFreq?.suggestion">
<span>建议</span>{{ result.supplierFreq.suggestion }}
</div>
<template
v-if="
result.supplierFreq?.dataAvailable ||
result.supplierFreq?.supplierName
"
>
<div v-if="result.supplierFreq.supplierName">
<span>{{ result.supplierFreq.supplierName }}</span>
<strong
>近3年异常 {{ result.supplierFreq.anomalyCount ?? 0 }} 次</strong
>
</div>
<div v-if="result.supplierFreq.alpha != null">
<span>频次风险系数</span
><strong>×{{ result.supplierFreq.alpha.toFixed(2) }}</strong>
</div>
<div v-if="result.supplierFreq.tier">
<span>供应商层级</span
><strong>{{ result.supplierFreq.tier }}</strong>
</div>
<div>
<span>合作频次(代理)</span>
<strong
>{{
result.supplierFreq.cooperationCount ??
result.supplierFreq.anomalyCount ??
0
}}
次</strong
>
</div>
<div v-if="result.supplierFreq.suggestion">
<span>管理建议</span>{{ result.supplierFreq.suggestion }}
</div>
</template>
<p v-else class="supplier-analysis__empty">
{{
result.supplierFreq?.note || "未指定供应商,暂无供应商频次分析。"
}}
</p>
<small
v-if="
result.supplierFreq?.dataAvailable ||
result.supplierFreq?.supplierName
"
class="supplier-analysis__note"
>
{{
result.supplierFreq.note ||
"当前以近3年异常记录数作为合作频次代理,不代表真实采购订单合作次数。"
}}
</small>
</section>
<section class="standards-card">
@ -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;
}

View File

@ -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({

View File

@ -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;