kx_guo 74a1c9d4bc 统一供应商画像展示并优化页面加载速度
供应商画像直接展示后端统一计算的基础分、频次风险系数和供应商得分;对齐对话卡片数据;未归类品类固定灰色展示并保留待补标记录;隐藏无推荐数据的代工厂模块;增加页面缓存、资源预加载、接口缓存和重复请求去重。
2026-09-09 10:46:14 +08:00

655 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import {
AlertTriangle,
BarChart3,
BookOpenCheck,
Bot,
Building2,
CheckCircle2,
ClipboardCheck,
PackageSearch,
Ruler,
Sparkles,
} from "@lucide/vue";
import { computed, ref } from "vue";
import { useRouter } from "vue-router";
import type { AssistantResponseCard } from "@/types/conversation";
const props = defineProps<{ card: AssistantResponseCard }>();
const router = useRouter();
const feedback = ref("");
interface CardData extends AssistantResponseCard {
name?: string;
partyType?: string;
material?: string;
anomalyCount?: number;
cooperationCount?: number | null;
frequencyCoefficient?: number;
supplierBaseScore?: number | null;
supplierScore?: number | null;
frequencyBasis?: string;
dataAvailable?: boolean;
tier?: string;
topProblems?: Array<{ name: string; count: number }>;
frequentProblems?: Array<{ name: string; count: number }>;
suggestions?: string[];
note?: string;
detectedValues?: Array<{ name: string; value: string }>;
matchedCount?: number;
standards?: Array<{
category: string;
riskPoint: string;
rule: string;
source?: string | null;
status: string;
}>;
confidence?: string;
analysisBase?: string;
risks?: Array<{ level: string; title: string; detail: string }>;
similarCases?: Array<{
date?: string | null;
product?: string | null;
problem?: string | null;
solution?: string | null;
similarity?: number | null;
}>;
warning?: string;
}
const data = computed(() => props.card as CardData);
function notify(label: string) {
feedback.value = `${label}功能将在对应模块接入后执行`;
window.setTimeout(() => (feedback.value = ""), 2400);
}
function openSupplier() {
const supplier = data.value.name;
void router.push({
name: "supplier",
query: supplier ? { supplier } : undefined,
});
}
function formatCount(value: unknown) {
return typeof value === "number" ? value.toLocaleString("zh-CN") : "暂无数据";
}
</script>
<template>
<article class="response-card" :class="`response-card--${card.type}`">
<header class="response-card__header">
<span class="response-card__icon">
<Building2 v-if="card.type === 'supplier'" :size="16" />
<PackageSearch v-else-if="card.type === 'material'" :size="16" />
<BookOpenCheck v-else-if="card.type === 'standard'" :size="16" />
<ClipboardCheck v-else-if="card.type === 'compliance'" :size="16" />
<Bot v-else :size="16" />
</span>
<div>
<h3>{{ card.title }}</h3>
<small v-if="card.type === 'supplier'"
>{{ data.name }} · {{ data.partyType }}</small
>
<small v-else-if="card.type === 'material'"
>{{ data.material }} · 历史异常分析</small
>
<small v-else-if="card.type === 'standard'"
>{{ data.category }} · 规则引擎</small
>
<small v-else-if="card.type === 'solution'"
>AI 推理生成 · 置信度:{{ data.confidence }}</small
>
<small v-else>标准库规则匹配 · 待品控确认</small>
</div>
<span v-if="card.type === 'solution'" class="solution-confidence"
>置信度:{{ data.confidence || "中" }}</span
>
</header>
<template v-if="card.type === 'supplier'">
<div class="metric-grid">
<div>
<b>{{ formatCount(data.anomalyCount) }}</b
><span>近3年异常</span>
</div>
<div v-if="data.cooperationCount != null">
<b>{{ formatCount(data.cooperationCount) }}</b
><span>合作频次(采购订单)</span>
</div>
<div>
<b>×{{ data.frequencyCoefficient?.toFixed(2) ?? "1.00" }}</b
><span>频次系数</span>
</div>
<div v-if="data.supplierScore != null">
<b>{{ Math.round(data.supplierScore) }} 分</b
><span>供应商得分 S供</span>
</div>
</div>
<div class="profile-line">
<span>供应商层级</span><strong>{{ data.tier }}</strong>
</div>
<section class="result-section">
<h4><BarChart3 :size="14" /> 高频异常</h4>
<div v-if="data.topProblems?.length" class="problem-list">
<div v-for="problem in data.topProblems" :key="problem.name">
<span>{{ problem.name }}</span
><b>{{ problem.count }} 次</b>
</div>
</div>
<p v-else class="empty">暂无匹配的异常类型</p>
</section>
<ul class="suggestion-list">
<li v-for="suggestion in data.suggestions" :key="suggestion">
{{ suggestion }}
</li>
</ul>
<p class="card-note">{{ data.note }}</p>
</template>
<template v-else-if="card.type === 'material'">
<div class="material-summary">
<b>{{ formatCount(data.anomalyCount) }}</b
><span>条历史异常涉及该物料</span>
</div>
<section class="result-section">
<h4><BarChart3 :size="14" /> 常见问题</h4>
<div v-if="data.frequentProblems?.length" class="problem-list">
<div v-for="problem in data.frequentProblems" :key="problem.name">
<span>{{ problem.name }}</span
><b>{{ problem.count }} 次</b>
</div>
</div>
<p v-else class="empty">暂无匹配历史异常</p>
</section>
<section v-if="data.standards?.length" class="result-section">
<h4><BookOpenCheck :size="14" /> 关联标准</h4>
<div
v-for="standard in data.standards"
:key="standard.riskPoint"
class="standard-line"
>
<span>{{ standard.category }}</span>
<p>
{{ standard.riskPoint }}<small>{{ standard.source }}</small>
</p>
</div>
</section>
<ul class="suggestion-list">
<li v-for="suggestion in data.suggestions" :key="suggestion">
{{ suggestion }}
</li>
</ul>
</template>
<template
v-else-if="card.type === 'standard' || card.type === 'compliance'"
>
<div
v-if="card.type === 'standard' && data.detectedValues?.length"
class="detected-values"
>
<span
v-for="item in data.detectedValues"
:key="`${item.name}-${item.value}`"
>{{ item.name }} <b>{{ item.value }}</b></span
>
</div>
<div class="standard-count">
<b>{{ data.matchedCount }}</b> 条规则已匹配
</div>
<section class="standard-results">
<template v-if="data.standards?.length">
<div
v-for="standard in data.standards"
:key="`${standard.category}-${standard.riskPoint}`"
class="standard-row"
>
<span>{{ standard.category }}</span>
<div>
<b>{{ standard.riskPoint }}</b
><small>{{ standard.rule }} · {{ standard.source }}</small>
</div>
<strong>{{ standard.status }}</strong>
</div>
</template>
<p v-else class="empty">暂无直接匹配规则</p>
</section>
<ul v-if="data.suggestions" class="suggestion-list">
<li v-for="suggestion in data.suggestions" :key="suggestion">
{{ suggestion }}
</li>
</ul>
<p v-if="data.note" class="card-note">{{ data.note }}</p>
</template>
<template v-else>
<div class="solution-base">
<b>分析基础:</b
>{{ data.analysisBase || "基于相似品类、相似物料与标准库规则生成。" }}
</div>
<section
v-if="data.standardFallbackEnabled || data.standards?.length"
class="solution-section"
>
<h4>
<Ruler :size="14" />
<span v-if="data.standardFallbackEnabled"
>标准兜底校验(无历史案例时启用)</span
>
<span v-else>关联标准校验</span>
</h4>
<div
v-for="standard in data.standards"
:key="`${standard.category}-${standard.riskPoint}`"
class="standard-row"
>
<span>{{ standard.category }}</span>
<div>
<b>{{ standard.riskPoint }}</b
><small>{{ standard.rule }} · {{ standard.source }}</small>
</div>
<strong>{{ standard.status }}</strong>
</div>
<p v-if="!data.standards?.length" class="empty">
当前标准库暂无可匹配规则,请补充产品品类、包材或检测指标。
</p>
</section>
<section v-if="data.similarCases?.length" class="solution-section">
<h4>相似历史案例</h4>
<div
v-for="(item, index) in data.similarCases"
:key="`${item.date}-${index}`"
class="standard-row"
>
<span>TOP {{ index + 1 }}</span>
<div>
<b>{{ item.product || "未命名产品" }}</b>
<small
>{{ item.problem || "异常问题未填写" }} →
{{ item.solution || "解决方案未填写" }}
<em v-if="item.similarity != null"
>相似度 {{ Math.round(item.similarity * 100) }}%</em
></small
>
</div>
</div>
</section>
<section v-if="data.risks?.length" class="solution-section risk-list">
<div v-for="risk in data.risks" :key="risk.title" class="solution-risk">
<span
:class="risk.level === '高风险' ? 'risk-high' : 'risk-medium'"
>{{ risk.level }}</span
>
<p>
<b>{{ risk.title }}:</b>{{ risk.detail }}
</p>
</div>
</section>
<section class="solution-suggest">
<h4><Sparkles :size="14" /> 建议方案(AI 生成,需品控确认)</h4>
<ol>
<li v-for="suggestion in data.suggestions" :key="suggestion">
{{ suggestion }}
</li>
</ol>
</section>
<div class="solution-warning">
<AlertTriangle :size="14" />{{
data.warning ||
"以上方案由 AI 推理生成,实际执行前请咨询品控并安排小批量试产验证。"
}}
</div>
</template>
<div class="response-actions">
<button
type="button"
class="response-actions__primary"
@click="notify('生成报告')"
>
<CheckCircle2 :size="14" />生成报告
</button>
<button type="button" @click="notify('查看详情')">查看详情</button>
<button type="button" @click="notify('追问详情')">追问详情</button>
<button
v-if="card.type === 'solution'"
type="button"
@click="notify('收藏方案')"
>
📌 收藏方案
</button>
<button
v-if="card.type === 'solution'"
type="button"
@click="openSupplier"
>
🏭 查供应商
</button>
</div>
<p v-if="feedback" class="response-feedback">{{ feedback }}</p>
</article>
</template>
<style scoped>
.response-card {
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 18px;
background: var(--color-surface);
box-shadow: var(--shadow-md);
}
.response-card--supplier {
border-left: 3px solid var(--color-success);
}
.response-card--material {
border-left: 3px solid var(--color-warning);
}
.response-card--standard,
.response-card--compliance {
border-left: 3px solid var(--color-accent);
}
.response-card--solution {
border: 1px solid #bfdbfe;
border-left: 3px solid var(--color-purple);
}
.response-card__header {
display: flex;
align-items: center;
gap: 9px;
border-bottom: 1px solid var(--color-border-light);
padding-bottom: 12px;
}
.response-card__icon {
display: grid;
width: 30px;
height: 30px;
place-items: center;
border-radius: 6px;
background: var(--color-bg-warm);
color: var(--color-accent);
}
.response-card--supplier .response-card__icon {
color: var(--color-success);
}
.response-card--material .response-card__icon {
color: var(--color-warning);
}
.response-card--solution .response-card__icon {
color: var(--color-purple);
}
.response-card__header h3 {
margin: 0;
font-size: 14px;
}
.response-card__header small {
color: var(--color-text-muted);
font-size: 10px;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 7px;
margin: 14px 0 10px;
}
.metric-grid > div {
border-radius: 6px;
padding: 10px;
background: var(--color-bg-warm);
}
.metric-grid b,
.metric-grid span {
display: block;
}
.metric-grid b {
color: var(--color-text);
font-size: 17px;
}
.metric-grid span {
margin-top: 3px;
color: var(--color-text-faint);
font-size: 10px;
}
.profile-line,
.material-summary,
.standard-count {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 9px 10px;
background: var(--color-success-soft);
color: var(--color-text-secondary);
font-size: 11px;
}
.profile-line strong {
color: var(--color-success);
}
.material-summary {
justify-content: flex-start;
margin-top: 14px;
background: var(--color-warning-soft);
}
.material-summary b {
color: var(--color-warning);
font-size: 17px;
}
.result-section,
.solution-section {
margin-top: 14px;
}
.result-section h4,
.solution-section h4,
.solution-suggest h4 {
display: flex;
align-items: center;
gap: 5px;
margin: 0 0 7px;
color: var(--color-text);
font-size: 12px;
}
.problem-list > div,
.standard-line,
.standard-row {
display: flex;
align-items: flex-start;
gap: 8px;
border-top: 1px dashed var(--color-border-light);
padding: 8px 0;
font-size: 11px;
}
.problem-list > div {
justify-content: space-between;
}
.problem-list b {
color: var(--color-danger);
}
.empty {
margin: 7px 0;
color: var(--color-text-faint);
font-size: 11px;
}
.standard-line > span,
.standard-row > span {
flex: 0 0 auto;
border-radius: 999px;
padding: 2px 6px;
background: var(--color-accent-subtle);
color: var(--color-accent);
font-size: 9px;
}
.standard-line p,
.standard-row div {
min-width: 0;
flex: 1;
margin: 0;
color: var(--color-text-secondary);
}
.standard-line small,
.standard-row small {
display: block;
margin-top: 3px;
color: var(--color-text-faint);
font-size: 9px;
}
.standard-row > strong {
flex: 0 0 auto;
color: var(--color-warning);
font-size: 10px;
}
.suggestion-list {
margin: 12px 0 0;
padding-left: 18px;
color: var(--color-text-secondary);
}
.suggestion-list li {
margin: 3px 0;
font-size: 11px;
line-height: 1.55;
}
.card-note {
margin: 10px 0 0;
color: var(--color-text-faint);
font-size: 10px;
line-height: 1.55;
}
.detected-values {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 14px 0 10px;
}
.detected-values span {
border: 1px solid var(--color-accent-soft);
border-radius: 5px;
padding: 5px 8px;
background: var(--color-accent-subtle);
color: var(--color-accent);
font-size: 11px;
}
.standard-count {
justify-content: flex-start;
margin-top: 14px;
background: var(--color-accent-subtle);
}
.standard-count b {
color: var(--color-accent);
font-size: 17px;
}
.standard-results {
margin-top: 5px;
}
.solution-base {
margin-top: 14px;
border-radius: 6px;
padding: 10px;
background: #f3f7fb;
color: var(--color-text-secondary);
font-size: 11px;
line-height: 1.6;
}
.solution-confidence {
margin-left: auto;
border: 1px solid #fcd34d;
border-radius: 999px;
padding: 3px 9px;
background: #fffbeb;
color: #d97706;
font-size: 10px;
font-weight: 700;
}
.solution-suggest {
margin-top: 14px;
border-radius: 6px;
padding: 11px;
background: #eff6ff;
}
.solution-suggest h4 {
color: var(--color-accent);
}
.solution-suggest ol {
margin: 0;
padding-left: 18px;
color: var(--color-text-secondary);
}
.solution-suggest li {
margin: 4px 0;
font-size: 11px;
line-height: 1.55;
}
.solution-risk {
display: flex;
gap: 7px;
border-top: 1px solid var(--color-border-light);
padding: 8px 0;
}
.solution-risk > span {
flex: 0 0 auto;
border-radius: 4px;
padding: 3px 5px;
background: var(--color-warning-soft);
color: var(--color-warning);
font-size: 9px;
}
.solution-risk > span.risk-high {
background: #fee2e2;
color: #dc2626;
}
.solution-risk > span.risk-medium {
background: #fef3c7;
color: #d97706;
}
.solution-risk p {
margin: 0;
color: var(--color-text-secondary);
font-size: 11px;
line-height: 1.5;
}
.solution-warning {
display: flex;
gap: 6px;
margin-top: 10px;
border-radius: 5px;
padding: 8px 10px;
background: var(--color-warning-soft);
color: #92400e;
font-size: 10px;
line-height: 1.5;
}
.response-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 14px;
border-top: 1px solid var(--color-border-light);
padding-top: 12px;
}
.response-actions button {
display: inline-flex;
align-items: center;
gap: 5px;
min-height: 30px;
border: 1px solid var(--color-border);
border-radius: 5px;
padding: 0 10px;
background: var(--color-surface);
color: var(--color-text-secondary);
font-size: 10px;
}
.response-actions__primary {
border-color: var(--color-text) !important;
background: var(--color-text) !important;
color: white !important;
}
.response-feedback {
margin: 8px 0 0;
color: var(--color-accent);
font-size: 10px;
}
@media (max-width: 600px) {
.metric-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>