dev #1
9
src/api/monitoring.ts
Normal file
9
src/api/monitoring.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { http } from "@/api/http";
|
||||
|
||||
export function addRiskMonitor(payload: { subjectId: string; score: number; level: string }) {
|
||||
return http.post("/v1/monitoring/risk", {
|
||||
subjectId: payload.subjectId,
|
||||
score: payload.score,
|
||||
level: payload.level,
|
||||
});
|
||||
}
|
||||
9
src/api/supplierProfile.ts
Normal file
9
src/api/supplierProfile.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { http } from "@/api/http";
|
||||
import type { ApiResponse } from "@/types/api";
|
||||
import type { SupplierProfile } from "@/types/supplierProfile";
|
||||
|
||||
export function fetchSupplierProfile(name: string) {
|
||||
return http.get<ApiResponse<SupplierProfile>>(
|
||||
`/v1/suppliers/${encodeURIComponent(name)}`,
|
||||
);
|
||||
}
|
||||
9
src/api/suppliers.ts
Normal file
9
src/api/suppliers.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { http } from "@/api/http";
|
||||
import type { ApiResponse } from "@/types/api";
|
||||
|
||||
export interface SupplierOption { name: string; anomalyCount: number; tier: string }
|
||||
export interface SupplierListData { items: SupplierOption[]; defaultSupplier: string | null }
|
||||
|
||||
export function fetchSupplierOptions(keyword?: string) {
|
||||
return http.get<ApiResponse<SupplierListData>>("/v1/suppliers", { params: { keyword, limit: 8 } });
|
||||
}
|
||||
@ -12,10 +12,12 @@ import {
|
||||
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;
|
||||
@ -59,6 +61,14 @@ function notify(label: string) {
|
||||
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") : "暂无数据";
|
||||
}
|
||||
@ -90,6 +100,9 @@ function formatCount(value: unknown) {
|
||||
>
|
||||
<small v-else>标准库规则匹配 · 待品控确认</small>
|
||||
</div>
|
||||
<span v-if="card.type === 'solution'" class="solution-confidence"
|
||||
>置信度:{{ data.confidence || "中" }}</span
|
||||
>
|
||||
</header>
|
||||
|
||||
<template v-if="card.type === 'supplier'">
|
||||
@ -205,7 +218,10 @@ function formatCount(value: unknown) {
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="solution-base"><b>分析基础:</b>{{ data.analysisBase }}</div>
|
||||
<div class="solution-base">
|
||||
<b>分析基础:</b
|
||||
>{{ data.analysisBase || "基于相似品类、相似物料与标准库规则生成。" }}
|
||||
</div>
|
||||
<section
|
||||
v-if="data.standardFallbackEnabled || data.standards?.length"
|
||||
class="solution-section"
|
||||
@ -255,7 +271,10 @@ function formatCount(value: unknown) {
|
||||
</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>{{ risk.level }}</span>
|
||||
<span
|
||||
:class="risk.level === '高风险' ? 'risk-high' : 'risk-medium'"
|
||||
>{{ risk.level }}</span
|
||||
>
|
||||
<p>
|
||||
<b>{{ risk.title }}:</b>{{ risk.detail }}
|
||||
</p>
|
||||
@ -270,7 +289,10 @@ function formatCount(value: unknown) {
|
||||
</ol>
|
||||
</section>
|
||||
<div class="solution-warning">
|
||||
<AlertTriangle :size="14" />{{ data.warning }}
|
||||
<AlertTriangle :size="14" />{{
|
||||
data.warning ||
|
||||
"以上方案由 AI 推理生成,实际执行前请咨询品控并安排小批量试产验证。"
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -284,6 +306,20 @@ function formatCount(value: unknown) {
|
||||
</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>
|
||||
@ -505,6 +541,16 @@ function formatCount(value: unknown) {
|
||||
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;
|
||||
@ -539,6 +585,14 @@ function formatCount(value: unknown) {
|
||||
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);
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
Sparkles,
|
||||
} from "@lucide/vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
import type { RiskAssessmentResult } from "@/types/risk";
|
||||
import { formatRiskLevel } from "@/utils/risk";
|
||||
@ -19,9 +20,13 @@ const props = defineProps<{
|
||||
result: RiskAssessmentResult;
|
||||
canMonitor?: boolean;
|
||||
}>();
|
||||
const router = useRouter();
|
||||
type SimilarCase = NonNullable<RiskAssessmentResult["similarCases"]>[number];
|
||||
const emit = defineEmits<{ generateReport: [] }>();
|
||||
const expanded = ref(true);
|
||||
const evidenceExpanded = ref(true);
|
||||
const feedback = ref("");
|
||||
const selectedCase = ref<SimilarCase | null>(null);
|
||||
|
||||
interface DimensionView {
|
||||
name: string;
|
||||
@ -44,6 +49,12 @@ function copyScore() {
|
||||
notify("评分已复制");
|
||||
}
|
||||
|
||||
function openCase(item?: SimilarCase) {
|
||||
const target = item ?? props.result.similarCases?.[0];
|
||||
if (target) selectedCase.value = target;
|
||||
else notify("案例详情");
|
||||
}
|
||||
|
||||
const dimensions = computed<DimensionView[]>(() =>
|
||||
(props.result.dimensionScores?.length
|
||||
? props.result.dimensionScores.map((item) => ({
|
||||
@ -78,6 +89,49 @@ const stars = computed(() => {
|
||||
const filled = Math.max(1, Math.min(5, Math.round(props.result.score / 20)));
|
||||
return "★".repeat(filled) + "☆".repeat(5 - filled);
|
||||
});
|
||||
const supplierDimension = computed(() =>
|
||||
props.result.dimensionScores?.find((item) => item.dimension === "供应商表现"),
|
||||
);
|
||||
const formulaText = computed(() => {
|
||||
const source = props.result.dimensionScores?.length
|
||||
? props.result.dimensionScores
|
||||
: props.result.factors.map((factor) => ({
|
||||
dimension: factor.name,
|
||||
score: 100 - factor.value,
|
||||
weight: factor.weight,
|
||||
}));
|
||||
if (!source.length) return "暂无七维评分明细";
|
||||
return `${source.map((item) => `${(item.weight / 100).toFixed(2)}×${Math.round(item.score)}`).join(" + ")} ≈ ${Math.round(props.result.score)}`;
|
||||
});
|
||||
const standardCategories = computed(() => [
|
||||
...new Set(
|
||||
(props.result.standardJudgment ?? []).map((item) => item.category),
|
||||
),
|
||||
]);
|
||||
const pendingStandardCount = computed(
|
||||
() =>
|
||||
(props.result.standardJudgment ?? []).filter(
|
||||
(item) => !["合规", "合格", "达标"].includes(item.status),
|
||||
).length,
|
||||
);
|
||||
|
||||
function openStandards(category = "all", keyword?: string) {
|
||||
void router.push({
|
||||
name: "standards",
|
||||
query: {
|
||||
category,
|
||||
...(keyword ? { keyword } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function openSupplier() {
|
||||
const supplier = props.result.supplierFreq?.supplierName;
|
||||
void router.push({
|
||||
name: "supplier",
|
||||
query: supplier ? { supplier } : undefined,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -138,6 +192,12 @@ const stars = computed(() => {
|
||||
<div v-if="result.categoryWarning?.suggestion">
|
||||
<span>建议</span>{{ result.categoryWarning.suggestion }}
|
||||
</div>
|
||||
<div v-if="result.categoryWarning?.similarCategory">
|
||||
<span>相似: {{ result.categoryWarning.similarCategory }}</span>
|
||||
历史异常
|
||||
{{ result.categoryWarning.similarCount ?? 0 }}
|
||||
条,用于当前品类案例不足时辅助预判
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="supplier-analysis">
|
||||
@ -158,6 +218,10 @@ const stars = computed(() => {
|
||||
<span>频次风险系数</span
|
||||
><strong>×{{ result.supplierFreq.alpha.toFixed(2) }}</strong>
|
||||
</div>
|
||||
<div v-if="supplierDimension">
|
||||
<span>供应商得分 S供</span
|
||||
><strong>{{ Math.round(supplierDimension.score) }} 分</strong>
|
||||
</div>
|
||||
<div v-if="result.supplierFreq.tier">
|
||||
<span>供应商层级</span
|
||||
><strong>{{ result.supplierFreq.tier }}</strong>
|
||||
@ -169,6 +233,9 @@ const stars = computed(() => {
|
||||
<div v-if="result.supplierFreq.suggestion">
|
||||
<span>管理建议</span>{{ result.supplierFreq.suggestion }}
|
||||
</div>
|
||||
<div v-if="supplierDimension?.reason" class="supplier-calculation">
|
||||
<span>计算依据</span>{{ supplierDimension.reason }}
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="supplier-analysis__empty">
|
||||
{{
|
||||
@ -191,16 +258,22 @@ const stars = computed(() => {
|
||||
|
||||
<section class="standards-card">
|
||||
<h3><BookOpenCheck :size="14" /> 标准判定 · 双引擎</h3>
|
||||
<p v-if="result.standardJudgment?.length" class="standards-card__intro">
|
||||
命中判定标准库 {{ result.standardJudgment.length }} 条 · 覆盖
|
||||
{{ standardCategories.join(" / ") }} · 来源:《标准文件.xlsx》219 条
|
||||
</p>
|
||||
<p
|
||||
v-if="!result.standardJudgment?.length"
|
||||
class="standards-card__empty"
|
||||
>
|
||||
暂无相关标准
|
||||
</p>
|
||||
<div
|
||||
<button
|
||||
v-for="standard in result.standardJudgment"
|
||||
:key="`${standard.category}-${standard.riskPoint}`"
|
||||
class="standard-row"
|
||||
type="button"
|
||||
@click="openStandards(standard.category, standard.riskPoint)"
|
||||
>
|
||||
<span
|
||||
class="standard-tag"
|
||||
@ -217,9 +290,26 @@ const stars = computed(() => {
|
||||
<strong :class="{ 'status-action': standard.status !== '待检测' }">{{
|
||||
standard.status
|
||||
}}</strong>
|
||||
</div>
|
||||
</button>
|
||||
<footer>
|
||||
双引擎融合:历史异常经验引擎提供风险优先级,标准库提供可执行的检测阈值与验证任务。
|
||||
<span class="standards-card__engine-icon">↻</span>
|
||||
<span
|
||||
><b>双引擎融合:</b
|
||||
>历史数据(经验引擎)提供风险优先级与相似案例;标准库(规则引擎)给出量化阈值与检测依据。<template
|
||||
v-if="pendingStandardCount"
|
||||
>{{ pendingStandardCount }} 项待检标准已加入检测任务清单</template
|
||||
></span
|
||||
>
|
||||
<span class="standards-card__links">
|
||||
<button
|
||||
v-for="category in standardCategories"
|
||||
:key="category"
|
||||
type="button"
|
||||
@click="openStandards(category)"
|
||||
>
|
||||
查看{{ category }}标准
|
||||
</button>
|
||||
</span>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
@ -245,6 +335,54 @@ const stars = computed(() => {
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="formula-strip">
|
||||
R = {{ formulaText }}({{ formatRiskLevel(result.level) }})
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="evidence-panel">
|
||||
<button
|
||||
type="button"
|
||||
class="evidence-panel__head"
|
||||
@click="evidenceExpanded = !evidenceExpanded"
|
||||
>
|
||||
<span>🧠 推理过程(思维链)</span><small>检索→匹配→评分→生成</small>
|
||||
<ChevronUp v-if="evidenceExpanded" :size="14" /><ChevronDown
|
||||
v-else
|
||||
:size="14"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="evidenceExpanded" class="evidence-panel__body">
|
||||
<div>
|
||||
<b>① 向量检索</b
|
||||
><span
|
||||
>新品参数嵌入 → 召回历史案例 → 余弦相似度与业务字段混合排序,返回
|
||||
Top {{ result.similarCases?.length ?? 0 }} 条。</span
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<b>② 相似度加权</b
|
||||
><span
|
||||
>按相似度和时间衰减因子 D(t)
|
||||
对候选案例排序,优先展示高相关案例。</span
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<b>③ 七维评分</b
|
||||
><span
|
||||
>{{ formulaText }},映射为{{
|
||||
formatRiskLevel(result.level)
|
||||
}}。</span
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<b>④ 建议生成</b
|
||||
><span
|
||||
>聚合相似案例方案,结合标准规则生成
|
||||
{{ suggestions.length }} 条可执行建议。</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="analysis-section">
|
||||
@ -263,6 +401,7 @@ const stars = computed(() => {
|
||||
:key="`${item.date}-${index}`"
|
||||
class="case-row"
|
||||
type="button"
|
||||
@click="openCase(item)"
|
||||
>
|
||||
<span class="case-rank">TOP {{ index + 1 }}</span>
|
||||
<div class="case-content">
|
||||
@ -296,14 +435,90 @@ const stars = computed(() => {
|
||||
>
|
||||
<FileDown :size="14" /> 生成报告
|
||||
</button>
|
||||
<button type="button" @click="notify('案例详情')">案例详情</button>
|
||||
<button type="button" @click="notify('供应商对比')">供应商对比</button>
|
||||
<button type="button" @click="openCase()">案例详情</button>
|
||||
<button type="button" @click="openSupplier">供应商对比</button>
|
||||
<button v-if="canMonitor" type="button" @click="notify('加入监控')">
|
||||
加入监控
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<p v-if="feedback" class="risk-feedback">{{ feedback }}</p>
|
||||
<div
|
||||
v-if="selectedCase"
|
||||
class="case-modal-backdrop"
|
||||
@click.self="selectedCase = null"
|
||||
>
|
||||
<section
|
||||
class="case-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="case-detail-title"
|
||||
>
|
||||
<header class="case-modal__header">
|
||||
<div>
|
||||
<span class="case-modal__eyebrow">SIMILAR CASE DETAIL</span>
|
||||
<h3 id="case-detail-title">案例详情</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="case-modal__close"
|
||||
aria-label="关闭案例详情"
|
||||
@click="selectedCase = null"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div class="case-modal__summary">
|
||||
<strong>{{ selectedCase.product || "未命名产品" }}</strong>
|
||||
<span v-if="selectedCase.similarity != null"
|
||||
>相似度 {{ Math.round(selectedCase.similarity * 100) }}%</span
|
||||
>
|
||||
</div>
|
||||
<dl class="case-modal__grid">
|
||||
<div>
|
||||
<dt>发生日期</dt>
|
||||
<dd>{{ selectedCase.date || "暂无数据" }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>产品品类</dt>
|
||||
<dd>{{ selectedCase.category || "暂无数据" }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>物料类型</dt>
|
||||
<dd>{{ selectedCase.material || "暂无数据" }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>缺陷等级</dt>
|
||||
<dd>{{ selectedCase.defectLevel || "暂无数据" }}</dd>
|
||||
</div>
|
||||
<div class="case-modal__field--wide">
|
||||
<dt>异常问题</dt>
|
||||
<dd>
|
||||
{{
|
||||
selectedCase.problem || selectedCase.description || "暂无数据"
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="case-modal__field--wide">
|
||||
<dt>根因分析</dt>
|
||||
<dd>{{ selectedCase.rootCause || "暂无数据" }}</dd>
|
||||
</div>
|
||||
<div class="case-modal__field--wide">
|
||||
<dt>解决方案</dt>
|
||||
<dd>{{ selectedCase.solution || "暂无数据" }}</dd>
|
||||
</div>
|
||||
<div class="case-modal__field--wide">
|
||||
<dt>处理结果</dt>
|
||||
<dd>{{ selectedCase.result || "暂无数据" }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<footer class="case-modal__footer">
|
||||
来源:历史异常案例库 · 案例编号:{{
|
||||
selectedCase.caseId || "暂无数据"
|
||||
}}
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
@ -532,6 +747,15 @@ h3 {
|
||||
color: #4c1d95;
|
||||
text-align: right;
|
||||
}
|
||||
.supplier-calculation {
|
||||
align-items: flex-start !important;
|
||||
margin-top: 5px;
|
||||
border-top: 1px dashed #ddd6fe;
|
||||
padding-top: 7px !important;
|
||||
color: var(--color-text-muted) !important;
|
||||
font-size: 10px !important;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.supplier-analysis__empty,
|
||||
.supplier-analysis__note {
|
||||
@ -555,15 +779,87 @@ h3 {
|
||||
color: var(--color-text-faint);
|
||||
font-size: 10px;
|
||||
}
|
||||
.formula-strip {
|
||||
margin-top: 10px;
|
||||
border: 1px dashed #bfdbfe;
|
||||
border-radius: 5px;
|
||||
padding: 8px 10px;
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
font-size: 10px;
|
||||
line-height: 1.55;
|
||||
text-align: center;
|
||||
}
|
||||
.evidence-panel {
|
||||
margin-top: 12px;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.evidence-panel__head {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 0;
|
||||
padding: 10px 12px;
|
||||
background: white;
|
||||
color: #111827;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.evidence-panel__head small {
|
||||
margin-left: auto;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 9px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.evidence-panel__body {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
padding: 4px 12px 8px;
|
||||
background: white;
|
||||
}
|
||||
.evidence-panel__body div {
|
||||
display: grid;
|
||||
grid-template-columns: 112px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
border-top: 1px dashed var(--color-border-light);
|
||||
padding: 8px 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 10px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.evidence-panel__body div:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
.evidence-panel__body b {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.standard-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
border-top: 1px dashed var(--color-border-light);
|
||||
border-right: 0;
|
||||
border-bottom: 0;
|
||||
border-left: 0;
|
||||
padding: 7px 0;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.standard-row:hover {
|
||||
background: var(--color-bg-warm);
|
||||
}
|
||||
|
||||
.standard-tag {
|
||||
@ -612,12 +908,48 @@ h3 {
|
||||
}
|
||||
|
||||
.standards-card footer {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 7px;
|
||||
border-radius: 6px;
|
||||
margin-top: 7px;
|
||||
padding: 7px 9px;
|
||||
background: var(--color-bg-warm);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 10px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.standards-card footer > span:nth-child(2) {
|
||||
flex: 1;
|
||||
}
|
||||
.standards-card footer b {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.standards-card footer button {
|
||||
flex: 0 0 auto;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--color-accent);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.standards-card footer button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.standards-card__links {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto !important;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 4px 8px;
|
||||
}
|
||||
.standards-card__engine-icon {
|
||||
color: var(--color-accent);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.analysis-section {
|
||||
@ -643,6 +975,17 @@ h3 {
|
||||
background: var(--color-bg-warm);
|
||||
text-align: center;
|
||||
}
|
||||
.dimension-cell:nth-child(1) .dimension-meter i,
|
||||
.dimension-cell:nth-child(5) .dimension-meter i,
|
||||
.dimension-cell:nth-child(6) .dimension-meter i {
|
||||
background: #d97706;
|
||||
}
|
||||
.dimension-cell:nth-child(2) .dimension-meter i {
|
||||
background: #16a34a;
|
||||
}
|
||||
.dimension-cell:nth-child(3) .dimension-meter i {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.dimension-cell > span {
|
||||
color: var(--color-text-muted);
|
||||
@ -780,6 +1123,114 @@ h3 {
|
||||
color: var(--color-accent);
|
||||
font-size: 11px;
|
||||
}
|
||||
.case-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgb(15 23 42 / 48%);
|
||||
}
|
||||
.case-modal {
|
||||
width: min(680px, 100%);
|
||||
max-height: min(720px, calc(100vh - 48px));
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
background: var(--color-surface);
|
||||
box-shadow: 0 22px 54px rgb(15 23 42 / 22%);
|
||||
}
|
||||
.case-modal__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
padding: 18px 20px;
|
||||
}
|
||||
.case-modal__eyebrow {
|
||||
color: var(--color-accent);
|
||||
font-size: 9px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.case-modal__header h3 {
|
||||
margin: 4px 0 0;
|
||||
font-size: 17px;
|
||||
}
|
||||
.case-modal__close {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.case-modal__summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px 10px;
|
||||
}
|
||||
.case-modal__summary strong {
|
||||
font-size: 15px;
|
||||
}
|
||||
.case-modal__summary span {
|
||||
border-radius: 4px;
|
||||
padding: 4px 7px;
|
||||
background: var(--color-accent-subtle);
|
||||
color: var(--color-accent);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.case-modal__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
padding: 10px 20px 20px;
|
||||
}
|
||||
.case-modal__grid div {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: 5px;
|
||||
padding: 9px 10px;
|
||||
}
|
||||
.case-modal__grid dt {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
.case-modal__grid dd {
|
||||
margin: 4px 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.case-modal__field--wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.case-modal__footer {
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
padding: 10px 20px 14px;
|
||||
color: var(--color-text-faint);
|
||||
font-size: 10px;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.case-modal-backdrop {
|
||||
align-items: end;
|
||||
padding: 0;
|
||||
}
|
||||
.case-modal {
|
||||
max-height: 90vh;
|
||||
border-radius: 10px 10px 0 0;
|
||||
}
|
||||
.case-modal__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.case-modal__field--wide {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.risk-card {
|
||||
|
||||
@ -33,7 +33,12 @@ const shortcuts = [
|
||||
route: "risk-item-entry",
|
||||
permission: "risk_item.submit",
|
||||
},
|
||||
{ label: "供应商画像", icon: Building2, route: "assessment" },
|
||||
{
|
||||
label: "供应商画像",
|
||||
icon: Building2,
|
||||
route: "supplier",
|
||||
permission: "supplier.read",
|
||||
},
|
||||
{
|
||||
label: "判定标准库",
|
||||
icon: BookOpenCheck,
|
||||
|
||||
@ -107,6 +107,10 @@ function openTool(
|
||||
router.push({ name: "review" });
|
||||
return;
|
||||
}
|
||||
if (value === "supplier") {
|
||||
router.push({ name: "supplier" });
|
||||
return;
|
||||
}
|
||||
if (value === "ocr") {
|
||||
ocrOpen.value = true;
|
||||
return;
|
||||
|
||||
@ -12,7 +12,8 @@ import {
|
||||
Search,
|
||||
X,
|
||||
} from "@lucide/vue";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
import { useAuthStore } from "@/stores/auth.store";
|
||||
import {
|
||||
@ -43,6 +44,7 @@ const categoryMeta: Record<string, { tone: string; description: string }> = {
|
||||
};
|
||||
|
||||
const keyword = ref("");
|
||||
const route = useRoute();
|
||||
const auth = useAuthStore();
|
||||
const category = ref("all");
|
||||
const status = ref("启用");
|
||||
@ -220,7 +222,20 @@ function formatDate(value: string) {
|
||||
}
|
||||
|
||||
watch(keyword, scheduleSearch);
|
||||
onMounted(() => void load());
|
||||
watch(
|
||||
() => [route.query.category, route.query.keyword],
|
||||
([categoryQuery, keywordQuery]) => {
|
||||
const requestedCategory =
|
||||
typeof categoryQuery === "string" ? categoryQuery : "all";
|
||||
category.value = categories.some((item) => item.key === requestedCategory)
|
||||
? requestedCategory
|
||||
: "all";
|
||||
keyword.value = typeof keywordQuery === "string" ? keywordQuery : "";
|
||||
page.value = 1;
|
||||
void load();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
onBeforeUnmount(() => {
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
});
|
||||
|
||||
463
src/pages/SupplierProfilePage.vue
Normal file
463
src/pages/SupplierProfilePage.vue
Normal file
@ -0,0 +1,463 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowLeft,
|
||||
BarChart3,
|
||||
Building2,
|
||||
CalendarDays,
|
||||
Factory,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
} from "@lucide/vue";
|
||||
import { computed, 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 profile = ref<SupplierProfile | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
const maxTrend = computed(() =>
|
||||
Math.max(
|
||||
1,
|
||||
...(profile.value?.monthlyTrend.map((item) => item.count) ?? [1]),
|
||||
),
|
||||
);
|
||||
const tierClass = computed(() => {
|
||||
if (profile.value?.tier.startsWith("C")) return "tier-c";
|
||||
if (profile.value?.tier.startsWith("B")) return "tier-b";
|
||||
if (profile.value?.tier.startsWith("A")) return "tier-a";
|
||||
return "tier-s";
|
||||
});
|
||||
|
||||
async function loadOptions() {
|
||||
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] ?? "");
|
||||
if (selected.value) await loadProfile();
|
||||
}
|
||||
async function loadProfile() {
|
||||
if (!selected.value) return;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const response = await fetchSupplierProfile(selected.value);
|
||||
profile.value = response.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());
|
||||
watch(
|
||||
() => route.query.supplier,
|
||||
(value) => {
|
||||
if (typeof value === "string" && value !== selected.value) {
|
||||
selected.value = value;
|
||||
void loadProfile();
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="supplier-page">
|
||||
<header class="supplier-head">
|
||||
<button class="back-button" type="button" @click="router.back">
|
||||
<ArrowLeft :size="15" />返回
|
||||
</button>
|
||||
<div>
|
||||
<p class="eyebrow">SUPPLIER PROFILE</p>
|
||||
<h1>供应商深度画像</h1>
|
||||
</div>
|
||||
<select
|
||||
v-model="selected"
|
||||
aria-label="选择供应商"
|
||||
@change="chooseSupplier"
|
||||
>
|
||||
<option v-for="name in supplierNames" :key="name" :value="name">
|
||||
{{ name }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-if="profile" class="tier-badge" :class="tierClass">{{
|
||||
profile.tier
|
||||
}}</span>
|
||||
</header>
|
||||
<p v-if="error" class="page-error">{{ error }}</p>
|
||||
<div v-if="loading" class="loading-state">
|
||||
<RefreshCw class="spin" :size="18" />正在加载供应商画像…
|
||||
</div>
|
||||
<template v-else-if="profile">
|
||||
<section class="stat-grid">
|
||||
<article>
|
||||
<Building2 :size="17" /><b>{{ profile.metric.anomalyTotal }}</b
|
||||
><small>异常总数 N</small>
|
||||
</article>
|
||||
<article>
|
||||
<CalendarDays :size="17" /><b>{{
|
||||
profile.metric.cooperationTotal ?? "暂无"
|
||||
}}</b
|
||||
><small>采购订单数</small>
|
||||
</article>
|
||||
<article>
|
||||
<BarChart3 :size="17" /><b
|
||||
>×{{ profile.metric.frequencyAlpha.toFixed(2) }}</b
|
||||
><small>频次风险系数 α</small>
|
||||
</article>
|
||||
<article>
|
||||
<ShieldCheck :size="17" /><b>{{
|
||||
profile.metric.riskScore ?? "暂无"
|
||||
}}</b
|
||||
><small>七维综合评分 R</small>
|
||||
</article>
|
||||
<article>
|
||||
<Factory :size="17" /><b>{{ profile.metric.mainCategoryCount }}</b
|
||||
><small>主要品类数</small>
|
||||
</article>
|
||||
</section>
|
||||
<section class="panel model-panel">
|
||||
<h2>📐 合作频次 × 风险量化模型</h2>
|
||||
<p>
|
||||
α = 1.0 +(采购订单数 ÷ 100)× 0.3(上限 1.5);供应商得分 S供 =
|
||||
基础分 × α(封顶100)。
|
||||
</p>
|
||||
<div class="basis">
|
||||
{{
|
||||
profile.metric.cooperationCountBasis
|
||||
}}。异常数仍按异常案例库统计,供应商层级按异常库口径判定。
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>📊 品类维度异常分布</h2>
|
||||
<div class="category-list">
|
||||
<div
|
||||
v-for="item in profile.categoryBreakdown"
|
||||
:key="item.category"
|
||||
class="category-row"
|
||||
>
|
||||
<span>{{ item.category }}</span>
|
||||
<div>
|
||||
<i
|
||||
:style="{
|
||||
width: `${Math.min(100, (item.anomalyCount / Math.max(1, profile.metric.anomalyTotal)) * 100)}%`,
|
||||
}"
|
||||
></i>
|
||||
</div>
|
||||
<b>{{ item.anomalyCount }}</b
|
||||
><small>{{ item.topProblem || "暂无高频问题" }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>📈 近六个月异常趋势</h2>
|
||||
<div class="trend">
|
||||
<div
|
||||
v-for="item in profile.monthlyTrend"
|
||||
:key="item.month"
|
||||
class="trend-col"
|
||||
>
|
||||
<span>{{ item.count }}</span
|
||||
><i
|
||||
:style="{
|
||||
height: `${Math.max(4, (item.count / maxTrend) * 100)}%`,
|
||||
}"
|
||||
></i
|
||||
><small>{{ item.month.slice(5) }}月</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>📅 近期异常记录</h2>
|
||||
<div v-if="profile.recentRecords.length" class="records">
|
||||
<div
|
||||
v-for="item in profile.recentRecords"
|
||||
:key="item.caseId"
|
||||
class="record"
|
||||
>
|
||||
<b>{{ item.month || "未知日期" }}</b
|
||||
><span
|
||||
>{{ item.product || "未命名产品" }} →
|
||||
{{ item.anomaly || "异常描述未填写" }}</span
|
||||
><em>{{ item.defectLevel || "未定级" }}</em>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="empty">暂无近期异常记录</p>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.supplier-page {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px 34px 48px;
|
||||
background: #f5f7fb;
|
||||
}
|
||||
.supplier-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
max-width: 1120px;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
.supplier-head h1,
|
||||
.supplier-head p {
|
||||
margin: 0;
|
||||
}
|
||||
.supplier-head h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
.eyebrow {
|
||||
color: #2563eb;
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
}
|
||||
.supplier-head select {
|
||||
margin-left: auto;
|
||||
min-width: 250px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 5px;
|
||||
padding: 9px;
|
||||
background: white;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
.back-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border: 1px solid #d5dbe5;
|
||||
border-radius: 5px;
|
||||
padding: 8px 10px;
|
||||
background: white;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tier-badge {
|
||||
border-radius: 999px;
|
||||
padding: 5px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.tier-s {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
.tier-a {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
.tier-b {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
.tier-c {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
.stat-grid,
|
||||
.panel {
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.stat-grid article {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 3px 8px;
|
||||
align-items: center;
|
||||
border: 1px solid #dbe3ed;
|
||||
border-radius: 7px;
|
||||
padding: 14px;
|
||||
background: white;
|
||||
}
|
||||
.stat-grid article svg {
|
||||
grid-row: 1/3;
|
||||
color: #2563eb;
|
||||
}
|
||||
.stat-grid b {
|
||||
font-size: 20px;
|
||||
}
|
||||
.stat-grid small {
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
}
|
||||
.panel {
|
||||
margin-top: 14px;
|
||||
border: 1px solid #dbe3ed;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
}
|
||||
.panel h2 {
|
||||
margin: 0 0 13px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.model-panel p {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
border-radius: 5px;
|
||||
background: #f8fafc;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.basis {
|
||||
margin-top: 8px;
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
}
|
||||
.category-row {
|
||||
display: grid;
|
||||
grid-template-columns: 110px minmax(100px, 1fr) 55px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin: 10px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.category-row div {
|
||||
height: 8px;
|
||||
border-radius: 5px;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
.category-row i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: #2563eb;
|
||||
}
|
||||
.category-row b {
|
||||
text-align: right;
|
||||
}
|
||||
.category-row small {
|
||||
grid-column: 1/-1;
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
}
|
||||
.trend {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
height: 170px;
|
||||
gap: 12px;
|
||||
padding: 10px 15px 0;
|
||||
border-bottom: 1px solid #cbd5e1;
|
||||
}
|
||||
.trend-col {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.trend-col span {
|
||||
font-size: 10px;
|
||||
color: #64748b;
|
||||
}
|
||||
.trend-col i {
|
||||
width: 65%;
|
||||
min-height: 4px;
|
||||
border-radius: 4px 4px 0 0;
|
||||
background: #2563eb;
|
||||
}
|
||||
.trend-col small {
|
||||
color: #64748b;
|
||||
font-size: 9px;
|
||||
}
|
||||
.records {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
}
|
||||
.record {
|
||||
display: grid;
|
||||
grid-template-columns: 75px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
border-top: 1px solid #eef2f7;
|
||||
padding: 10px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.record b {
|
||||
color: #2563eb;
|
||||
}
|
||||
.record span {
|
||||
color: #334155;
|
||||
}
|
||||
.record em {
|
||||
color: #d97706;
|
||||
font-style: normal;
|
||||
font-size: 10px;
|
||||
}
|
||||
.empty,
|
||||
.page-error,
|
||||
.loading-state {
|
||||
max-width: 1120px;
|
||||
margin: 50px auto;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
}
|
||||
.page-error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
.spin {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.supplier-page {
|
||||
padding: 18px 12px;
|
||||
}
|
||||
.supplier-head {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.supplier-head select {
|
||||
order: 4;
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
.stat-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.record {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.trend {
|
||||
gap: 5px;
|
||||
}
|
||||
}
|
||||
@media print {
|
||||
.back-button,
|
||||
.supplier-head select {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -51,6 +51,16 @@ export const routes: RouteRecordRaw[] = [
|
||||
permission: "risk.assess",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "supplier",
|
||||
name: "supplier",
|
||||
component: () => import("@/pages/SupplierProfilePage.vue"),
|
||||
meta: {
|
||||
title: "供应商画像",
|
||||
requiresAuth: true,
|
||||
permission: "supplier.read",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "overview",
|
||||
name: "overview",
|
||||
|
||||
@ -39,10 +39,17 @@ export interface RiskAssessmentResult {
|
||||
modelVersion: string;
|
||||
factors: RiskFactorResult[];
|
||||
similarCases?: Array<{
|
||||
caseId?: number | null;
|
||||
date?: string | null;
|
||||
product?: string | null;
|
||||
category?: string | null;
|
||||
material?: string | null;
|
||||
problem?: string | null;
|
||||
rootCause?: string | null;
|
||||
solution?: string | null;
|
||||
description?: string | null;
|
||||
defectLevel?: string | null;
|
||||
result?: string | null;
|
||||
similarity?: number | null;
|
||||
}>;
|
||||
aiSuggestions?: string[];
|
||||
|
||||
41
src/types/supplierProfile.ts
Normal file
41
src/types/supplierProfile.ts
Normal file
@ -0,0 +1,41 @@
|
||||
export interface SupplierCategoryBreakdown {
|
||||
category: string;
|
||||
anomalyCount: number;
|
||||
anomalyRate: number | null;
|
||||
topMaterial: string | null;
|
||||
topProblem: string | null;
|
||||
}
|
||||
|
||||
export interface SupplierRecentRecord {
|
||||
caseId: number;
|
||||
month: string | null;
|
||||
product: string | null;
|
||||
anomaly: string | null;
|
||||
defectLevel: string | null;
|
||||
}
|
||||
|
||||
export interface SupplierProfile {
|
||||
supplierName: string;
|
||||
tier: string;
|
||||
tierCondition: string;
|
||||
metric: {
|
||||
cooperationTotal: number | null;
|
||||
anomalyTotal: number;
|
||||
anomalyRate: number | null;
|
||||
frequencyAlpha: number;
|
||||
riskScore: number | null;
|
||||
mainCategoryCount: number;
|
||||
cooperationCountBasis: string;
|
||||
anomalyRateBasis: string;
|
||||
};
|
||||
categoryBreakdown: SupplierCategoryBreakdown[];
|
||||
monthlyTrend: Array<{ month: string; count: number }>;
|
||||
recentRecords: SupplierRecentRecord[];
|
||||
factoryRecommendations: Array<{
|
||||
category: string;
|
||||
preferredFactory: string | null;
|
||||
alternativeFactory: string | null;
|
||||
reason: string;
|
||||
}>;
|
||||
recommendationNote: string | null;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user