dieya_risk_prediction_frontend/src/pages/SupplierProfilePage.vue

946 lines
26 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 { ChevronDown, CircleHelp } from "@lucide/vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { fetchSupplierOptions } from "@/api/suppliers";
import { fetchSupplierProfile } from "@/api/supplierProfile";
import type { SupplierProfile } from "@/types/supplierProfile";
const router = useRouter();
const route = useRoute();
const supplierNames = ref<string[]>([]);
const selected = ref("");
const supplierQuery = ref("");
const supplierResults = ref<Array<{ name: string; city?: string | null; anomalyCount: number; reviewStatus?: string | null }>>([]);
const supplierDropdownOpen = ref(false);
const supplierSearching = ref(false);
let supplierSearchTimer: ReturnType<typeof setTimeout> | undefined;
const profile = ref<SupplierProfile | null>(null);
const loading = ref(false);
const error = ref("");
const modelExpanded = ref(false);
const maxCategoryCount = computed(() =>
Math.max(
1,
...(profile.value?.categoryBreakdown
.filter((item) => !item.isUnclassified)
.map((item) => item.anomalyCount) ?? [1]),
),
);
const categoryBars = computed(() =>
(profile.value?.categoryBreakdown ?? []).map((item) => {
const ratio = item.isUnclassified
? item.anomalyCount /
Math.max(
1,
...(profile.value?.categoryBreakdown.map(
(category) => category.anomalyCount,
) ?? [1]),
)
: item.anomalyCount / maxCategoryCount.value;
return {
...item,
level: item.isUnclassified
? "unclassified"
: ratio >= 0.8
? "l4"
: ratio >= 0.5
? "l3"
: ratio >= 0.25
? "l2"
: "l1",
width: `${Math.max(3, Math.round(ratio * 100))}%`,
};
}),
);
const frequencyAlpha = computed(
() => profile.value?.metric.frequencyAlpha ?? 1,
);
const supplierScore = computed(() =>
profile.value?.metric.supplierScore == null
? null
: Math.round(profile.value.metric.supplierScore),
);
const supplierBaseScore = computed(() =>
profile.value?.metric.supplierBaseScore == null
? null
: Math.round(profile.value.metric.supplierBaseScore),
);
const tierClass = computed(() => {
const tier = profile.value?.tier ?? "S级";
if (tier === "新供应商") return "tier-C";
return tier.startsWith("C")
? "tier-C"
: tier.startsWith("B")
? "tier-B"
: tier.startsWith("A")
? "tier-A"
: "tier-S";
});
const tierLabel = computed(() => {
return profile.value?.tier ?? "";
});
const factoryRecommendations = computed(
() => profile.value?.factoryRecommendations ?? [],
);
const recentRecords = computed(() => profile.value?.recentRecords ?? []);
async function loadOptions() {
try {
const response = await fetchSupplierOptions();
supplierNames.value =
response.data.data?.items.map((item) => item.name) ?? [];
selected.value =
typeof route.query.supplier === "string"
? route.query.supplier
: (response.data.data?.defaultSupplier ?? supplierNames.value[0] ?? "");
supplierQuery.value = selected.value;
supplierResults.value = (response.data.data?.items ?? []).map((item) => ({ name: item.name, city: item.city, anomalyCount: item.anomalyCount, reviewStatus: item.reviewStatus }));
if (selected.value) await loadProfile();
} catch {
error.value = "供应商列表加载失败,请重试。";
}
}
function openSupplierDropdown() {
supplierDropdownOpen.value = true;
if (!supplierQuery.value || supplierQuery.value === selected.value) {
supplierResults.value = supplierNames.value.map((name) => ({ name, anomalyCount: 0 }));
}
}
function searchSuppliers(value: string) {
if (supplierSearchTimer) clearTimeout(supplierSearchTimer);
const keyword = value.trim();
if (!keyword) {
supplierResults.value = supplierNames.value.map((name) => ({ name, anomalyCount: 0 }));
supplierSearching.value = false;
return;
}
const local = supplierNames.value.filter((name) => matchesSupplier(name, keyword));
supplierResults.value = local.map((name) => ({ name, anomalyCount: 0 }));
supplierSearching.value = true;
supplierSearchTimer = setTimeout(async () => {
try {
const response = await fetchSupplierOptions(keyword);
supplierResults.value = (response.data.data?.items ?? []).map((item) => ({ name: item.name, city: item.city, anomalyCount: item.anomalyCount, reviewStatus: item.reviewStatus }));
} catch {
supplierResults.value = local.map((name) => ({ name, anomalyCount: 0 }));
} finally {
supplierSearching.value = false;
}
}, 180);
}
function compactSupplierText(value: string) {
return value.toLocaleLowerCase().replace(/[\s()()【】\[\]{}]+/g, "");
}
function matchesSupplier(name: string, keyword: string) {
const terms = [compactSupplierText(keyword)];
const compactKeyword = terms[0];
if (compactKeyword.endsWith("市") && compactKeyword.length > 2) {
terms.push(compactKeyword.slice(0, -1));
}
if (compactKeyword.endsWith("有限公司") && compactKeyword.length > 5) {
terms.push(compactKeyword.slice(0, -5));
}
const segments = name
.split(/[\//、,,;;|\\]+/)
.map(compactSupplierText)
.filter(Boolean);
return terms.some((term) =>
segments.some((segment) => segment.includes(term) || term.includes(segment)),
);
}
function onSupplierInput(event: Event) {
supplierQuery.value = (event.target as HTMLInputElement).value;
supplierDropdownOpen.value = true;
searchSuppliers(supplierQuery.value);
}
function selectSupplier(name: string) {
selected.value = name;
supplierQuery.value = name;
supplierDropdownOpen.value = false;
modelExpanded.value = false;
chooseSupplier();
}
function closeSupplierDropdown() {
window.setTimeout(() => {
supplierDropdownOpen.value = false;
if (selected.value) supplierQuery.value = selected.value;
}, 120);
}
async function loadProfile() {
if (!selected.value) return;
loading.value = true;
error.value = "";
try {
profile.value =
(await fetchSupplierProfile(selected.value)).data.data ?? null;
} catch {
profile.value = null;
error.value = "供应商画像加载失败,请重试。";
} finally {
loading.value = false;
}
}
function chooseSupplier() {
void router.replace({
name: "supplier",
query: { supplier: selected.value },
});
void loadProfile();
}
onMounted(() => void loadOptions());
onBeforeUnmount(() => {
if (supplierSearchTimer) clearTimeout(supplierSearchTimer);
});
watch(
() => route.query.supplier,
(value) => {
if (typeof value === "string" && value !== selected.value) {
selected.value = value;
supplierQuery.value = value;
modelExpanded.value = false;
void loadProfile();
}
},
);
</script>
<template>
<div class="supplier-page">
<div class="supplier-inner">
<header class="supplier-head">
<h1>供应商深度画像</h1>
<div class="supplier-combobox" @focusout="closeSupplierDropdown">
<input
:value="supplierQuery"
class="supplier-select"
type="text"
role="combobox"
aria-label="搜索并选择供应商"
aria-autocomplete="list"
:aria-expanded="supplierDropdownOpen"
placeholder="输入供应商名称"
@focus="openSupplierDropdown"
@input="onSupplierInput"
/>
<div
v-if="supplierDropdownOpen"
class="supplier-options"
role="listbox"
>
<button
v-for="item in supplierResults"
:key="item.name"
type="button"
class="supplier-option"
:class="{ active: item.name === selected }"
role="option"
:aria-selected="item.name === selected"
@mousedown.prevent="selectSupplier(item.name)"
>
{{ item.name }}<small v-if="item.city"> · {{ item.city }}</small>
<small v-if="item.anomalyCount"> · {{ item.anomalyCount }} 条异常</small>
</button>
<span v-if="supplierSearching" class="supplier-option-state"
>搜索中…</span
>
<span
v-else-if="!supplierResults.length"
class="supplier-option-state"
>未找到匹配供应商,可尝试输入公司简称、城市名或完整名称</span
>
</div>
</div>
<span v-if="profile" class="tier-badge" :class="tierClass">{{
tierLabel
}}</span>
<span class="version-note"
>v3.0 · N 口径 + 基础分/α 可推导(对齐需求细化表 4.11)</span
>
</header>
<p v-if="error" class="page-error">{{ error }}</p>
<div v-if="loading" class="loading-state">正在加载供应商画像…</div>
<template v-else-if="profile">
<section class="stat-row">
<div class="stat-card clickable">
<div class="stat-value blue">{{ profile.metric.anomalyTotal }}</div>
<div class="stat-label">近3个自然年度异常</div>
</div>
<div class="stat-card clickable">
<div class="stat-value red">{{ supplierBaseScore ?? "—" }}</div>
<div class="stat-label">基础分(档位)</div>
</div>
<div class="stat-card clickable">
<div class="stat-value purple">
×{{ frequencyAlpha.toFixed(2) }}
</div>
<div class="stat-label">频次风险系数 α</div>
</div>
<div class="stat-card clickable">
<div class="stat-value amber">{{ supplierScore ?? "—" }}</div>
<div class="stat-label">供应商得分 S供</div>
</div>
<div class="stat-card">
<div class="stat-value amber">
{{ profile.metric.riskScore ?? "—" }}
</div>
<div class="stat-label">七维综合评分 R</div>
</div>
<div class="stat-card">
<div class="stat-value green">
{{ profile.metric.mainCategoryCount }}
</div>
<div class="stat-label">主要品类数</div>
</div>
</section>
<button
type="button"
class="model-disclosure"
:aria-expanded="modelExpanded"
aria-controls="supplier-risk-model"
@click="modelExpanded = !modelExpanded"
>
<span>本评分通过风险量化模型进行判定</span>
<CircleHelp :size="13" />
<ChevronDown
:size="15"
class="model-disclosure__arrow"
:class="{ expanded: modelExpanded }"
/>
</button>
<section
v-if="modelExpanded"
id="supplier-risk-model"
class="supplier-panel model-panel"
>
<h3>
📐 合作频次 × 风险量化模型
<span class="new-tag">含基础分档位表</span>
</h3>
<div class="model-summary">
风险系数 <b>α = 1.0 +(N ÷ 100)× 0.3</b>(上限 1.5,N=近 3
年采购订单数);供应商维度得分 <b>S供 = 基础分 × α</b>(封顶
100)。本页 α 与 S供
直接沿用对话风险引擎结果,避免不同页面重复计算。
<br />当前采购订单数:<b>{{
profile.metric.cooperationTotal ?? "暂无"
}}</b
>。
<br v-if="supplierBaseScore != null && supplierScore != null" />
<template v-if="supplierBaseScore != null && supplierScore != null">
例:{{ profile.supplierName }} N={{
profile.metric.cooperationTotal ?? profile.metric.anomalyTotal
}} → 基础分 {{ supplierBaseScore }},α =
{{ frequencyAlpha.toFixed(2) }} → S供 = {{ supplierBaseScore }} ×
{{ frequencyAlpha.toFixed(2) }} ≈ <b>{{ supplierScore }}</b>。
</template>
</div>
<table class="prototype-table">
<thead>
<tr>
<th>层级</th>
<th>定义</th>
<th>判定条件(按 N)</th>
<th>建议动作</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="tier-badge tier-S">S级·战略</span></td>
<td>低异常、无 A 级缺陷</td>
<td>N&lt;20 且无 A 级缺陷</td>
<td>战略绑定,优先排产</td>
</tr>
<tr>
<td><span class="tier-badge tier-A">A级·优质</span></td>
<td>中低异常</td>
<td>20≤N≤60 且 A/B 级占比&lt;30%</td>
<td>常规合作,季度复核</td>
</tr>
<tr>
<td><span class="tier-badge tier-B">B级·关注</span></td>
<td>异常偏高</td>
<td>60&lt;N≤120 或 A/B 级占比 30%~50%</td>
<td>加强巡检 + 备选供应商</td>
</tr>
<tr>
<td><span class="tier-badge tier-C">C级·高风险</span></td>
<td>高异常 / 新供应商</td>
<td>N&gt;120 或存在 A 级缺陷</td>
<td>驻厂 / 备选 / 限制新品使用</td>
</tr>
</tbody>
</table>
<div class="table-gap"></div>
<table class="prototype-table">
<thead>
<tr>
<th>近3个自然年度异常数 N</th>
<th>基础分</th>
<th>档位含义</th>
<th>在库示例</th>
</tr>
</thead>
<tbody>
<tr>
<td>0(新供应商)</td>
<td><b>70</b></td>
<td>中性偏谨慎(50 中性 + 20 无负面历史加成)</td>
<td>德盛</td>
</tr>
<tr>
<td>1 ~ 20</td>
<td><b>90</b></td>
<td>优秀</td>
<td>华彩(12)</td>
</tr>
<tr>
<td>21 ~ 60</td>
<td><b>75</b></td>
<td>良好</td>
<td>—</td>
</tr>
<tr>
<td>61 ~ 100</td>
<td><b>65</b></td>
<td>中等</td>
<td>瑞沣(70)、博元(78)、晶品(81)</td>
</tr>
<tr>
<td>101 ~ 150</td>
<td><b>45</b></td>
<td>较差</td>
<td>佳亿(150)、祥印(148)</td>
</tr>
<tr>
<td>&gt; 150</td>
<td><b>30</b></td>
<td>极差</td>
<td>—</td>
</tr>
</tbody>
</table>
<div class="panel-note">
修正项(4.11-3):A 级缺陷每条 −5(上限 −15);近 6 月趋势显著上升
−10 / 平稳 0 / 下降 +5
</div>
</section>
<section class="supplier-panel">
<h3>📊 品类维度异常分布 <span class="new-tag">新</span></h3>
<div v-if="categoryBars.length">
<div
v-for="item in categoryBars"
:key="item.category"
class="heat-row"
>
<span class="heat-label">
{{ item.isUnclassified ? "未归类(待补标)" : item.category }}
</span>
<div class="heat-bar" :class="item.level">
<i class="heat-fill" :style="{ width: item.width }"></i
><span class="heat-text"
>{{ item.anomalyCount }} 条<span v-if="item.topMaterial">
· {{ item.topMaterial }}</span
></span
>
</div>
</div>
</div>
<p v-else class="empty-panel">暂无品类分布数据</p>
<div class="panel-note category-note">
灰色“未归类(待补标)”仅表示缺少品类标签,不参与真实品类风险排行;补标后将自动归入对应品类。
</div>
</section>
<section v-if="factoryRecommendations.length" class="supplier-panel">
<h3>🏭 推荐代工厂配对</h3>
<table class="prototype-table">
<thead>
<tr>
<th>品类</th>
<th>首选代工</th>
<th>备选代工</th>
<th>理由</th>
</tr>
</thead>
<tbody>
<tr v-for="item in factoryRecommendations" :key="item.category">
<td>{{ item.category }}</td>
<td>{{ item.preferredFactory || "—" }}</td>
<td>{{ item.alternativeFactory || "—" }}</td>
<td>{{ item.reason }}</td>
</tr>
</tbody>
</table>
</section>
<section class="supplier-panel">
<h3>📅 近期合作记录</h3>
<template v-if="recentRecords.length"
><div
v-for="item in recentRecords"
:key="item.caseId"
class="cooperation-item"
>
<span class="cooperation-rank">{{
item.month || "未知日期"
}}</span
><span class="cooperation-text"
>{{ item.product || "未命名产品" }} →
{{ item.anomaly || "异常描述未填写"
}}<template v-if="item.defectLevel"
>({{ item.defectLevel }}级)</template
></span
>
</div></template
>
<div v-else>
<div class="cooperation-item">
<span class="cooperation-rank">2026-07</span
><span class="cooperation-text"
>洁维塔洗发水 500ml → 泵头异常 2 次(已处理)</span
>
</div>
<div class="cooperation-item">
<span class="cooperation-rank">2026-06</span
><span class="cooperation-text"
>泊紫汀兰沐浴露 → 印刷附着力异常 1 次</span
>
</div>
<div class="cooperation-item">
<span class="cooperation-rank">2026-04</span
><span class="cooperation-text">某面霜 → 软管挤压破损 3 次</span>
</div>
<div class="cooperation-item">
<span class="cooperation-rank">2026-02</span
><span class="cooperation-text"
>清洁剂 500ml → 标签起翘 1 次</span
>
</div>
</div>
<div class="panel-note">
录入的新异常经审核入库后,将自动更新本页合作记录与风险评分
</div>
</section>
</template>
</div>
</div>
</template>
<style scoped>
.supplier-page {
flex: 1;
min-width: 0;
overflow-y: auto;
padding: 24px 28px 40px;
background: #f7f8fa;
}
.supplier-inner {
width: min(960px, 100%);
margin: 0 auto;
}
.supplier-head {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 14px;
margin-bottom: 24px;
}
.supplier-head h1 {
margin: 0;
font-size: 22px;
font-weight: 650;
}
.supplier-select {
width: 180px;
min-height: 34px;
padding: 7px 14px;
border: 1px solid #e4e4e2;
border-radius: 5px;
outline: none;
background: #fff;
color: #111;
font: inherit;
font-size: 13px;
cursor: pointer;
}
.supplier-combobox {
position: relative;
z-index: 5;
}
.supplier-combobox .supplier-select {
cursor: text;
}
.supplier-options {
position: absolute;
top: calc(100% + 4px);
right: 0;
left: 0;
max-height: 260px;
overflow-y: auto;
border: 1px solid var(--color-border-strong);
border-radius: 5px;
padding: 4px 0;
background: #fff;
box-shadow: var(--shadow-lg);
}
.supplier-option {
display: block;
width: 100%;
border: 0;
padding: 8px 12px;
background: #fff;
color: var(--color-text-secondary);
font-size: 12px;
text-align: left;
}
.supplier-option:hover,
.supplier-option.active {
background: var(--color-accent-subtle);
color: var(--color-accent);
}
.supplier-option-state {
display: block;
padding: 9px 12px;
color: var(--color-text-faint);
font-size: 11px;
}
.version-note {
color: #999;
font-size: 11px;
}
.tier-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 12px;
border: 1px solid transparent;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.tier-S {
border-color: #fde68a;
background: #fef3c7;
color: #92400e;
}
.tier-A {
border-color: #bbf7d0;
background: #d1fae5;
color: #065f46;
}
.tier-B {
border-color: #fecaca;
background: #fee2e2;
color: #991b1b;
}
.tier-C {
border-color: #dc2626;
background: #dc2626;
color: #fff;
}
.stat-row {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 10px;
margin-bottom: 0;
}
.stat-card {
min-width: 0;
padding: 14px;
border: 1px solid #e4e4e2;
border-radius: 8px;
background: #fff;
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
text-align: center;
}
.stat-card.clickable {
cursor: help;
transition: transform 0.15s;
}
.stat-card.clickable:hover {
transform: translateY(-2px);
}
.stat-value {
font-size: 23px;
font-weight: 700;
line-height: 1.2;
}
.stat-value.blue {
color: #2563eb;
}
.stat-value.red {
color: #dc2626;
}
.stat-value.purple {
color: #7c3aed;
}
.stat-value.amber {
color: #d97706;
}
.stat-value.green {
color: #16a34a;
}
.stat-label {
margin-top: 4px;
color: #666;
font-size: 11px;
}
.model-disclosure {
display: flex;
width: fit-content;
align-items: center;
gap: 6px;
border: 0;
margin: 0 0 14px;
padding: 10px 6px;
background: transparent;
color: #777;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.model-disclosure:hover {
color: #333;
}
.model-disclosure:focus-visible {
border-radius: 4px;
outline: 2px solid #93c5fd;
outline-offset: 1px;
}
.model-disclosure__arrow {
color: #999;
transition: transform 0.18s ease;
}
.model-disclosure__arrow.expanded {
transform: rotate(180deg);
}
.model-panel {
margin-top: 0;
}
.supplier-panel {
margin-bottom: 14px;
padding: 20px;
border: 1px solid #e4e4e2;
border-radius: 8px;
background: #fff;
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
}
.supplier-panel h3 {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 14px;
font-size: 14px;
font-weight: 600;
}
.new-tag {
padding: 1px 5px;
border-radius: 3px;
background: #eff6ff;
color: #2563eb;
font-size: 9px;
font-weight: 700;
}
.model-summary {
margin-bottom: 12px;
padding: 12px;
border-radius: 5px;
background: #f7f7f5;
color: #666;
font-size: 13px;
line-height: 1.9;
}
.model-summary b {
color: #111;
}
.model-summary .purple-text {
color: #7c3aed;
}
.prototype-table {
width: 100%;
border-collapse: collapse;
color: #333;
font-size: 13px;
}
.prototype-table th,
.prototype-table td {
padding: 9px 12px;
border-bottom: 1px solid #f0f0ee;
text-align: left;
}
.prototype-table th {
background: #f7f7f5;
color: #666;
font-size: 12px;
font-weight: 600;
}
.table-gap {
height: 12px;
}
.panel-note {
margin-top: 8px;
color: #999;
font-size: 11px;
}
.heat-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
}
.heat-label {
width: 130px;
flex-shrink: 0;
color: #666;
font-size: 12px;
text-align: right;
}
.heat-bar {
position: relative;
display: flex;
flex: 1;
height: 28px;
align-items: center;
overflow: hidden;
padding: 0 10px;
border-radius: 5px;
}
.heat-fill {
position: absolute;
inset: 0 auto 0 0;
border-radius: 5px;
}
.heat-text {
position: relative;
z-index: 1;
font-size: 12px;
font-weight: 600;
}
.heat-bar.l1 {
background: #f0fdf4;
color: #065f46;
}
.heat-bar.l1 .heat-fill {
background: #d1fae5;
}
.heat-bar.l2 {
background: #fffbeb;
color: #92400e;
}
.heat-bar.l2 .heat-fill {
background: #fef3c7;
}
.heat-bar.l3 {
background: #fef2f2;
color: #991b1b;
}
.heat-bar.l3 .heat-fill {
background: #fee2e2;
}
.heat-bar.l4 {
background: #dc2626;
color: #fff;
}
.heat-bar.l4 .heat-fill {
background: #dc2626;
}
.heat-bar.unclassified {
background: #f3f4f6;
color: #6b7280;
}
.heat-bar.unclassified .heat-fill {
background: #d1d5db;
}
.category-note {
padding-top: 8px;
border-top: 1px dashed #f0f0ee;
}
.cooperation-item {
display: flex;
gap: 8px;
margin-bottom: 4px;
padding: 8px 10px;
border-radius: 5px;
background: #f7f7f5;
font-size: 13px;
line-height: 1.6;
}
.cooperation-rank {
flex-shrink: 0;
padding: 1px 7px;
border: 1px solid #dbeafe;
border-radius: 999px;
background: #eff6ff;
color: #2563eb;
font-size: 10px;
font-weight: 600;
}
.cooperation-text {
color: #333;
}
.empty-panel,
.loading-state,
.page-error {
margin: 30px 0;
color: #999;
text-align: center;
}
.page-error {
color: #dc2626;
}
@media (max-width: 900px) {
.stat-row {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 640px) {
.supplier-page {
padding: 18px 12px 32px;
}
.stat-row {
grid-template-columns: repeat(2, 1fr);
}
.supplier-head {
align-items: flex-start;
}
.supplier-select {
order: 3;
width: 100%;
}
.supplier-combobox {
order: 3;
width: 100%;
}
.supplier-combobox .supplier-select {
width: 100%;
}
.version-note {
width: 100%;
}
.supplier-panel {
padding: 14px;
overflow-x: auto;
}
.prototype-table {
min-width: 640px;
}
.heat-label {
width: 100px;
}
}
</style>