Compare commits
2 Commits
1cc6b471d3
...
74a1c9d4bc
| Author | SHA1 | Date | |
|---|---|---|---|
| 74a1c9d4bc | |||
| 904198ae8b |
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
38
src/api/supplierProfile.ts
Normal file
38
src/api/supplierProfile.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { http } from "@/api/http";
|
||||||
|
import type { ApiResponse } from "@/types/api";
|
||||||
|
import type { SupplierProfile } from "@/types/supplierProfile";
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 30_000;
|
||||||
|
const cache = new Map<string, { data: SupplierProfile; at: number }>();
|
||||||
|
const pending = new Map<string, Promise<SupplierProfile>>();
|
||||||
|
|
||||||
|
export async function fetchSupplierProfile(
|
||||||
|
name: string,
|
||||||
|
): Promise<{ data: { data: SupplierProfile | null } }> {
|
||||||
|
const key = name.trim();
|
||||||
|
const cached = cache.get(key);
|
||||||
|
if (cached && Date.now() - cached.at < CACHE_TTL_MS) {
|
||||||
|
return { data: { data: cached.data } };
|
||||||
|
}
|
||||||
|
const running = pending.get(key);
|
||||||
|
if (running) return { data: { data: await running } };
|
||||||
|
const request = (async () => {
|
||||||
|
const response = await http.get<ApiResponse<SupplierProfile>>(
|
||||||
|
`/v1/suppliers/${encodeURIComponent(key)}`,
|
||||||
|
);
|
||||||
|
if (!response.data.data) throw new Error("供应商画像响应为空");
|
||||||
|
cache.set(key, { data: response.data.data, at: Date.now() });
|
||||||
|
return response.data.data;
|
||||||
|
})();
|
||||||
|
pending.set(key, request);
|
||||||
|
try {
|
||||||
|
return { data: { data: await request } };
|
||||||
|
} finally {
|
||||||
|
pending.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSupplierProfileCache(name?: string) {
|
||||||
|
if (name) cache.delete(name.trim());
|
||||||
|
else cache.clear();
|
||||||
|
}
|
||||||
44
src/api/suppliers.ts
Normal file
44
src/api/suppliers.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 30_000;
|
||||||
|
let cached: { data: SupplierListData; at: number } | null = null;
|
||||||
|
let pending: Promise<SupplierListData> | null = null;
|
||||||
|
|
||||||
|
export async function fetchSupplierOptions(
|
||||||
|
keyword?: string,
|
||||||
|
): Promise<{ data: { data: SupplierListData } }> {
|
||||||
|
const normalized = keyword?.trim() || "";
|
||||||
|
if (!normalized && cached && Date.now() - cached.at < CACHE_TTL_MS)
|
||||||
|
return { data: { data: cached.data } };
|
||||||
|
if (!normalized && pending) return { data: { data: await pending } };
|
||||||
|
const request = (async () => {
|
||||||
|
const response = await http.get<ApiResponse<SupplierListData>>(
|
||||||
|
"/v1/suppliers",
|
||||||
|
{ params: { keyword: normalized || undefined, limit: 8 } },
|
||||||
|
);
|
||||||
|
if (!response.data.data) throw new Error("供应商列表响应为空");
|
||||||
|
if (!normalized) cached = { data: response.data.data, at: Date.now() };
|
||||||
|
return response.data.data;
|
||||||
|
})();
|
||||||
|
if (!normalized) pending = request;
|
||||||
|
try {
|
||||||
|
return { data: { data: await request } };
|
||||||
|
} finally {
|
||||||
|
if (!normalized) pending = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSupplierOptionsCache() {
|
||||||
|
cached = null;
|
||||||
|
}
|
||||||
@ -12,10 +12,12 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
} from "@lucide/vue";
|
} from "@lucide/vue";
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
|
||||||
import type { AssistantResponseCard } from "@/types/conversation";
|
import type { AssistantResponseCard } from "@/types/conversation";
|
||||||
|
|
||||||
const props = defineProps<{ card: AssistantResponseCard }>();
|
const props = defineProps<{ card: AssistantResponseCard }>();
|
||||||
|
const router = useRouter();
|
||||||
const feedback = ref("");
|
const feedback = ref("");
|
||||||
interface CardData extends AssistantResponseCard {
|
interface CardData extends AssistantResponseCard {
|
||||||
name?: string;
|
name?: string;
|
||||||
@ -24,6 +26,8 @@ interface CardData extends AssistantResponseCard {
|
|||||||
anomalyCount?: number;
|
anomalyCount?: number;
|
||||||
cooperationCount?: number | null;
|
cooperationCount?: number | null;
|
||||||
frequencyCoefficient?: number;
|
frequencyCoefficient?: number;
|
||||||
|
supplierBaseScore?: number | null;
|
||||||
|
supplierScore?: number | null;
|
||||||
frequencyBasis?: string;
|
frequencyBasis?: string;
|
||||||
dataAvailable?: boolean;
|
dataAvailable?: boolean;
|
||||||
tier?: string;
|
tier?: string;
|
||||||
@ -59,6 +63,14 @@ function notify(label: string) {
|
|||||||
window.setTimeout(() => (feedback.value = ""), 2400);
|
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) {
|
function formatCount(value: unknown) {
|
||||||
return typeof value === "number" ? value.toLocaleString("zh-CN") : "暂无数据";
|
return typeof value === "number" ? value.toLocaleString("zh-CN") : "暂无数据";
|
||||||
}
|
}
|
||||||
@ -90,6 +102,9 @@ function formatCount(value: unknown) {
|
|||||||
>
|
>
|
||||||
<small v-else>标准库规则匹配 · 待品控确认</small>
|
<small v-else>标准库规则匹配 · 待品控确认</small>
|
||||||
</div>
|
</div>
|
||||||
|
<span v-if="card.type === 'solution'" class="solution-confidence"
|
||||||
|
>置信度:{{ data.confidence || "中" }}</span
|
||||||
|
>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<template v-if="card.type === 'supplier'">
|
<template v-if="card.type === 'supplier'">
|
||||||
@ -106,6 +121,10 @@ function formatCount(value: unknown) {
|
|||||||
<b>×{{ data.frequencyCoefficient?.toFixed(2) ?? "1.00" }}</b
|
<b>×{{ data.frequencyCoefficient?.toFixed(2) ?? "1.00" }}</b
|
||||||
><span>频次系数</span>
|
><span>频次系数</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="data.supplierScore != null">
|
||||||
|
<b>{{ Math.round(data.supplierScore) }} 分</b
|
||||||
|
><span>供应商得分 S供</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="profile-line">
|
<div class="profile-line">
|
||||||
<span>供应商层级</span><strong>{{ data.tier }}</strong>
|
<span>供应商层级</span><strong>{{ data.tier }}</strong>
|
||||||
@ -205,7 +224,10 @@ function formatCount(value: unknown) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="solution-base"><b>分析基础:</b>{{ data.analysisBase }}</div>
|
<div class="solution-base">
|
||||||
|
<b>分析基础:</b
|
||||||
|
>{{ data.analysisBase || "基于相似品类、相似物料与标准库规则生成。" }}
|
||||||
|
</div>
|
||||||
<section
|
<section
|
||||||
v-if="data.standardFallbackEnabled || data.standards?.length"
|
v-if="data.standardFallbackEnabled || data.standards?.length"
|
||||||
class="solution-section"
|
class="solution-section"
|
||||||
@ -255,7 +277,10 @@ function formatCount(value: unknown) {
|
|||||||
</section>
|
</section>
|
||||||
<section v-if="data.risks?.length" class="solution-section risk-list">
|
<section v-if="data.risks?.length" class="solution-section risk-list">
|
||||||
<div v-for="risk in data.risks" :key="risk.title" class="solution-risk">
|
<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>
|
<p>
|
||||||
<b>{{ risk.title }}:</b>{{ risk.detail }}
|
<b>{{ risk.title }}:</b>{{ risk.detail }}
|
||||||
</p>
|
</p>
|
||||||
@ -270,7 +295,10 @@ function formatCount(value: unknown) {
|
|||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
<div class="solution-warning">
|
<div class="solution-warning">
|
||||||
<AlertTriangle :size="14" />{{ data.warning }}
|
<AlertTriangle :size="14" />{{
|
||||||
|
data.warning ||
|
||||||
|
"以上方案由 AI 推理生成,实际执行前请咨询品控并安排小批量试产验证。"
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -284,6 +312,20 @@ function formatCount(value: unknown) {
|
|||||||
</button>
|
</button>
|
||||||
<button type="button" @click="notify('查看详情')">查看详情</button>
|
<button type="button" @click="notify('查看详情')">查看详情</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>
|
</div>
|
||||||
<p v-if="feedback" class="response-feedback">{{ feedback }}</p>
|
<p v-if="feedback" class="response-feedback">{{ feedback }}</p>
|
||||||
</article>
|
</article>
|
||||||
@ -505,6 +547,16 @@ function formatCount(value: unknown) {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 1.6;
|
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 {
|
.solution-suggest {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@ -539,6 +591,14 @@ function formatCount(value: unknown) {
|
|||||||
color: var(--color-warning);
|
color: var(--color-warning);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
|
.solution-risk > span.risk-high {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
.solution-risk > span.risk-medium {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
.solution-risk p {
|
.solution-risk p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
} from "@lucide/vue";
|
} from "@lucide/vue";
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
|
||||||
import type { RiskAssessmentResult } from "@/types/risk";
|
import type { RiskAssessmentResult } from "@/types/risk";
|
||||||
import { formatRiskLevel } from "@/utils/risk";
|
import { formatRiskLevel } from "@/utils/risk";
|
||||||
@ -19,9 +20,13 @@ const props = defineProps<{
|
|||||||
result: RiskAssessmentResult;
|
result: RiskAssessmentResult;
|
||||||
canMonitor?: boolean;
|
canMonitor?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
const router = useRouter();
|
||||||
|
type SimilarCase = NonNullable<RiskAssessmentResult["similarCases"]>[number];
|
||||||
const emit = defineEmits<{ generateReport: [] }>();
|
const emit = defineEmits<{ generateReport: [] }>();
|
||||||
const expanded = ref(true);
|
const expanded = ref(true);
|
||||||
|
const evidenceExpanded = ref(true);
|
||||||
const feedback = ref("");
|
const feedback = ref("");
|
||||||
|
const selectedCase = ref<SimilarCase | null>(null);
|
||||||
|
|
||||||
interface DimensionView {
|
interface DimensionView {
|
||||||
name: string;
|
name: string;
|
||||||
@ -44,6 +49,12 @@ function copyScore() {
|
|||||||
notify("评分已复制");
|
notify("评分已复制");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCase(item?: SimilarCase) {
|
||||||
|
const target = item ?? props.result.similarCases?.[0];
|
||||||
|
if (target) selectedCase.value = target;
|
||||||
|
else notify("案例详情");
|
||||||
|
}
|
||||||
|
|
||||||
const dimensions = computed<DimensionView[]>(() =>
|
const dimensions = computed<DimensionView[]>(() =>
|
||||||
(props.result.dimensionScores?.length
|
(props.result.dimensionScores?.length
|
||||||
? props.result.dimensionScores.map((item) => ({
|
? 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)));
|
const filled = Math.max(1, Math.min(5, Math.round(props.result.score / 20)));
|
||||||
return "★".repeat(filled) + "☆".repeat(5 - filled);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -138,6 +192,12 @@ const stars = computed(() => {
|
|||||||
<div v-if="result.categoryWarning?.suggestion">
|
<div v-if="result.categoryWarning?.suggestion">
|
||||||
<span>建议</span>{{ result.categoryWarning.suggestion }}
|
<span>建议</span>{{ result.categoryWarning.suggestion }}
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="result.categoryWarning?.similarCategory">
|
||||||
|
<span>相似: {{ result.categoryWarning.similarCategory }}</span>
|
||||||
|
历史异常
|
||||||
|
{{ result.categoryWarning.similarCount ?? 0 }}
|
||||||
|
条,用于当前品类案例不足时辅助预判
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="supplier-analysis">
|
<section class="supplier-analysis">
|
||||||
@ -158,6 +218,10 @@ const stars = computed(() => {
|
|||||||
<span>频次风险系数</span
|
<span>频次风险系数</span
|
||||||
><strong>×{{ result.supplierFreq.alpha.toFixed(2) }}</strong>
|
><strong>×{{ result.supplierFreq.alpha.toFixed(2) }}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="supplierDimension">
|
||||||
|
<span>供应商得分 S供</span
|
||||||
|
><strong>{{ Math.round(supplierDimension.score) }} 分</strong>
|
||||||
|
</div>
|
||||||
<div v-if="result.supplierFreq.tier">
|
<div v-if="result.supplierFreq.tier">
|
||||||
<span>供应商层级</span
|
<span>供应商层级</span
|
||||||
><strong>{{ result.supplierFreq.tier }}</strong>
|
><strong>{{ result.supplierFreq.tier }}</strong>
|
||||||
@ -169,6 +233,9 @@ const stars = computed(() => {
|
|||||||
<div v-if="result.supplierFreq.suggestion">
|
<div v-if="result.supplierFreq.suggestion">
|
||||||
<span>管理建议</span>{{ result.supplierFreq.suggestion }}
|
<span>管理建议</span>{{ result.supplierFreq.suggestion }}
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="supplierDimension?.reason" class="supplier-calculation">
|
||||||
|
<span>计算依据</span>{{ supplierDimension.reason }}
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<p v-else class="supplier-analysis__empty">
|
<p v-else class="supplier-analysis__empty">
|
||||||
{{
|
{{
|
||||||
@ -191,16 +258,22 @@ const stars = computed(() => {
|
|||||||
|
|
||||||
<section class="standards-card">
|
<section class="standards-card">
|
||||||
<h3><BookOpenCheck :size="14" /> 标准判定 · 双引擎</h3>
|
<h3><BookOpenCheck :size="14" /> 标准判定 · 双引擎</h3>
|
||||||
|
<p v-if="result.standardJudgment?.length" class="standards-card__intro">
|
||||||
|
命中判定标准库 {{ result.standardJudgment.length }} 条 · 覆盖
|
||||||
|
{{ standardCategories.join(" / ") }} · 来源:《标准文件.xlsx》219 条
|
||||||
|
</p>
|
||||||
<p
|
<p
|
||||||
v-if="!result.standardJudgment?.length"
|
v-if="!result.standardJudgment?.length"
|
||||||
class="standards-card__empty"
|
class="standards-card__empty"
|
||||||
>
|
>
|
||||||
暂无相关标准
|
暂无相关标准
|
||||||
</p>
|
</p>
|
||||||
<div
|
<button
|
||||||
v-for="standard in result.standardJudgment"
|
v-for="standard in result.standardJudgment"
|
||||||
:key="`${standard.category}-${standard.riskPoint}`"
|
:key="`${standard.category}-${standard.riskPoint}`"
|
||||||
class="standard-row"
|
class="standard-row"
|
||||||
|
type="button"
|
||||||
|
@click="openStandards(standard.category, standard.riskPoint)"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="standard-tag"
|
class="standard-tag"
|
||||||
@ -217,9 +290,26 @@ const stars = computed(() => {
|
|||||||
<strong :class="{ 'status-action': standard.status !== '待检测' }">{{
|
<strong :class="{ 'status-action': standard.status !== '待检测' }">{{
|
||||||
standard.status
|
standard.status
|
||||||
}}</strong>
|
}}</strong>
|
||||||
</div>
|
</button>
|
||||||
<footer>
|
<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>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -245,6 +335,54 @@ const stars = computed(() => {
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<section class="analysis-section">
|
<section class="analysis-section">
|
||||||
@ -263,6 +401,7 @@ const stars = computed(() => {
|
|||||||
:key="`${item.date}-${index}`"
|
:key="`${item.date}-${index}`"
|
||||||
class="case-row"
|
class="case-row"
|
||||||
type="button"
|
type="button"
|
||||||
|
@click="openCase(item)"
|
||||||
>
|
>
|
||||||
<span class="case-rank">TOP {{ index + 1 }}</span>
|
<span class="case-rank">TOP {{ index + 1 }}</span>
|
||||||
<div class="case-content">
|
<div class="case-content">
|
||||||
@ -296,14 +435,90 @@ const stars = computed(() => {
|
|||||||
>
|
>
|
||||||
<FileDown :size="14" /> 生成报告
|
<FileDown :size="14" /> 生成报告
|
||||||
</button>
|
</button>
|
||||||
<button type="button" @click="notify('案例详情')">案例详情</button>
|
<button type="button" @click="openCase()">案例详情</button>
|
||||||
<button type="button" @click="notify('供应商对比')">供应商对比</button>
|
<button type="button" @click="openSupplier">供应商对比</button>
|
||||||
<button v-if="canMonitor" type="button" @click="notify('加入监控')">
|
<button v-if="canMonitor" type="button" @click="notify('加入监控')">
|
||||||
加入监控
|
加入监控
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<p v-if="feedback" class="risk-feedback">{{ feedback }}</p>
|
<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>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -532,6 +747,15 @@ h3 {
|
|||||||
color: #4c1d95;
|
color: #4c1d95;
|
||||||
text-align: right;
|
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__empty,
|
||||||
.supplier-analysis__note {
|
.supplier-analysis__note {
|
||||||
@ -555,15 +779,87 @@ h3 {
|
|||||||
color: var(--color-text-faint);
|
color: var(--color-text-faint);
|
||||||
font-size: 10px;
|
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 {
|
.standard-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
border-top: 1px dashed var(--color-border-light);
|
border-top: 1px dashed var(--color-border-light);
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 0;
|
||||||
|
border-left: 0;
|
||||||
padding: 7px 0;
|
padding: 7px 0;
|
||||||
|
background: transparent;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.standard-row:hover {
|
||||||
|
background: var(--color-bg-warm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.standard-tag {
|
.standard-tag {
|
||||||
@ -612,12 +908,48 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.standards-card footer {
|
.standards-card footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 7px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
margin-top: 7px;
|
margin-top: 7px;
|
||||||
padding: 7px 9px;
|
padding: 7px 9px;
|
||||||
background: var(--color-bg-warm);
|
background: var(--color-bg-warm);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: 10px;
|
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 {
|
.analysis-section {
|
||||||
@ -643,6 +975,17 @@ h3 {
|
|||||||
background: var(--color-bg-warm);
|
background: var(--color-bg-warm);
|
||||||
text-align: center;
|
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 {
|
.dimension-cell > span {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
@ -780,6 +1123,114 @@ h3 {
|
|||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
font-size: 11px;
|
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) {
|
@media (max-width: 600px) {
|
||||||
.risk-card {
|
.risk-card {
|
||||||
|
|||||||
@ -20,6 +20,22 @@ const visibleNavigation = computed(() =>
|
|||||||
(item) => item !== "管理" || auth.hasPermission("risk_item.review"),
|
(item) => item !== "管理" || auth.hasPermission("risk_item.review"),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const routeChunks: Record<string, () => Promise<unknown>> = {
|
||||||
|
看板: () => import("@/pages/OverviewPage.vue"),
|
||||||
|
供应商: () => import("@/pages/SupplierProfilePage.vue"),
|
||||||
|
标准库: () => import("@/pages/StandardsPage.vue"),
|
||||||
|
报告: () => import("@/pages/ReportPage.vue"),
|
||||||
|
管理: () => import("@/pages/ReviewPage.vue"),
|
||||||
|
};
|
||||||
|
const prefetched = new Set<string>();
|
||||||
|
function prefetch(item: string) {
|
||||||
|
if (prefetched.has(item)) return;
|
||||||
|
const loader = routeChunks[item];
|
||||||
|
if (!loader) return;
|
||||||
|
prefetched.add(item);
|
||||||
|
void loader();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -52,6 +68,8 @@ const visibleNavigation = computed(() =>
|
|||||||
(item === '管理' && route.name === 'review') ||
|
(item === '管理' && route.name === 'review') ||
|
||||||
(item === '对话' && route.name === 'assessment'),
|
(item === '对话' && route.name === 'assessment'),
|
||||||
}"
|
}"
|
||||||
|
@mouseenter="prefetch(item)"
|
||||||
|
@focus="prefetch(item)"
|
||||||
>
|
>
|
||||||
{{ item }}
|
{{ item }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|||||||
@ -33,7 +33,12 @@ const shortcuts = [
|
|||||||
route: "risk-item-entry",
|
route: "risk-item-entry",
|
||||||
permission: "risk_item.submit",
|
permission: "risk_item.submit",
|
||||||
},
|
},
|
||||||
{ label: "供应商画像", icon: Building2, route: "assessment" },
|
{
|
||||||
|
label: "供应商画像",
|
||||||
|
icon: Building2,
|
||||||
|
route: "supplier",
|
||||||
|
permission: "supplier.read",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "判定标准库",
|
label: "判定标准库",
|
||||||
icon: BookOpenCheck,
|
icon: BookOpenCheck,
|
||||||
@ -48,7 +53,23 @@ function canReview() {
|
|||||||
return auth.hasPermission("risk_item.review");
|
return auth.hasPermission("risk_item.review");
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => conversationStore.loadList());
|
onMounted(() => {
|
||||||
|
// Keep the first non-chat navigation fast by warming the small route chunks
|
||||||
|
// after the browser has finished the initial render. This does not block the
|
||||||
|
// chat page and makes subsequent clicks resolve from the module cache.
|
||||||
|
const warmRoutes = () => {
|
||||||
|
void import("@/pages/OverviewPage.vue");
|
||||||
|
void import("@/pages/SupplierProfilePage.vue");
|
||||||
|
void import("@/pages/StandardsPage.vue");
|
||||||
|
void import("@/pages/ReportPage.vue");
|
||||||
|
void import("@/pages/ReviewPage.vue");
|
||||||
|
void import("@/pages/RiskItemEntryPage.vue");
|
||||||
|
};
|
||||||
|
const idle = window.requestIdleCallback;
|
||||||
|
if (idle) idle(warmRoutes, { timeout: 1500 });
|
||||||
|
else window.setTimeout(warmRoutes, 500);
|
||||||
|
void conversationStore.loadList();
|
||||||
|
});
|
||||||
|
|
||||||
function startNewChat() {
|
function startNewChat() {
|
||||||
conversationStore.reset();
|
conversationStore.reset();
|
||||||
|
|||||||
@ -17,7 +17,11 @@ onMounted(() => appStore.refreshHealth());
|
|||||||
<div class="app-shell__body">
|
<div class="app-shell__body">
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<main class="app-content">
|
<main class="app-content">
|
||||||
<RouterView />
|
<RouterView v-slot="{ Component }">
|
||||||
|
<KeepAlive :max="8">
|
||||||
|
<component :is="Component" />
|
||||||
|
</KeepAlive>
|
||||||
|
</RouterView>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -107,6 +107,10 @@ function openTool(
|
|||||||
router.push({ name: "review" });
|
router.push({ name: "review" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (value === "supplier") {
|
||||||
|
router.push({ name: "supplier" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (value === "ocr") {
|
if (value === "ocr") {
|
||||||
ocrOpen.value = true;
|
ocrOpen.value = true;
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -12,7 +12,8 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
X,
|
X,
|
||||||
} from "@lucide/vue";
|
} 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 { useAuthStore } from "@/stores/auth.store";
|
||||||
import {
|
import {
|
||||||
@ -43,6 +44,7 @@ const categoryMeta: Record<string, { tone: string; description: string }> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const keyword = ref("");
|
const keyword = ref("");
|
||||||
|
const route = useRoute();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const category = ref("all");
|
const category = ref("all");
|
||||||
const status = ref("启用");
|
const status = ref("启用");
|
||||||
@ -220,7 +222,20 @@ function formatDate(value: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(keyword, scheduleSearch);
|
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(() => {
|
onBeforeUnmount(() => {
|
||||||
if (searchTimer) clearTimeout(searchTimer);
|
if (searchTimer) clearTimeout(searchTimer);
|
||||||
});
|
});
|
||||||
|
|||||||
717
src/pages/SupplierProfilePage.vue
Normal file
717
src/pages/SupplierProfilePage.vue
Normal file
@ -0,0 +1,717 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
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 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] ?? "");
|
||||||
|
if (selected.value) await loadProfile();
|
||||||
|
} catch {
|
||||||
|
error.value = "供应商列表加载失败,请重试。";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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());
|
||||||
|
watch(
|
||||||
|
() => route.query.supplier,
|
||||||
|
(value) => {
|
||||||
|
if (typeof value === "string" && value !== selected.value) {
|
||||||
|
selected.value = value;
|
||||||
|
void loadProfile();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="supplier-page">
|
||||||
|
<div class="supplier-inner">
|
||||||
|
<header class="supplier-head">
|
||||||
|
<h1>供应商深度画像</h1>
|
||||||
|
<select
|
||||||
|
v-model="selected"
|
||||||
|
class="supplier-select"
|
||||||
|
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">{{
|
||||||
|
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>
|
||||||
|
|
||||||
|
<section class="supplier-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
|
||||||
|
>。
|
||||||
|
</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<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 级占比<30%</td>
|
||||||
|
<td>常规合作,季度复核</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="tier-badge tier-B">B级·关注</span></td>
|
||||||
|
<td>异常偏高</td>
|
||||||
|
<td>60<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>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>> 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 {
|
||||||
|
padding: 7px 14px;
|
||||||
|
border: 1px solid #e4e4e2;
|
||||||
|
border-radius: 5px;
|
||||||
|
outline: none;
|
||||||
|
background: #fff;
|
||||||
|
color: #111;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.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: 20px;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.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%;
|
||||||
|
}
|
||||||
|
.version-note {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.supplier-panel {
|
||||||
|
padding: 14px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.prototype-table {
|
||||||
|
min-width: 640px;
|
||||||
|
}
|
||||||
|
.heat-label {
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -51,6 +51,16 @@ export const routes: RouteRecordRaw[] = [
|
|||||||
permission: "risk.assess",
|
permission: "risk.assess",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "supplier",
|
||||||
|
name: "supplier",
|
||||||
|
component: () => import("@/pages/SupplierProfilePage.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "供应商画像",
|
||||||
|
requiresAuth: true,
|
||||||
|
permission: "supplier.read",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "overview",
|
path: "overview",
|
||||||
name: "overview",
|
name: "overview",
|
||||||
|
|||||||
@ -1,10 +1,31 @@
|
|||||||
import { fetchDashboard } from "@/api/dashboard";
|
import { fetchDashboard } from "@/api/dashboard";
|
||||||
import type { DashboardData, DashboardYear } from "@/types/dashboard";
|
import type { DashboardData, DashboardYear } from "@/types/dashboard";
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 30_000;
|
||||||
|
const cache = new Map<DashboardYear, { data: DashboardData; at: number }>();
|
||||||
|
const pending = new Map<DashboardYear, Promise<DashboardData>>();
|
||||||
|
|
||||||
export async function getDashboard(
|
export async function getDashboard(
|
||||||
year: DashboardYear,
|
year: DashboardYear,
|
||||||
): Promise<DashboardData> {
|
): Promise<DashboardData> {
|
||||||
|
const cached = cache.get(year);
|
||||||
|
if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.data;
|
||||||
|
const running = pending.get(year);
|
||||||
|
if (running) return running;
|
||||||
|
const request = (async () => {
|
||||||
const response = await fetchDashboard(year);
|
const response = await fetchDashboard(year);
|
||||||
if (!response.data.data) throw new Error("Dashboard response is empty");
|
if (!response.data.data) throw new Error("Dashboard response is empty");
|
||||||
|
cache.set(year, { data: response.data.data, at: Date.now() });
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
|
})();
|
||||||
|
pending.set(year, request);
|
||||||
|
try {
|
||||||
|
return await request;
|
||||||
|
} finally {
|
||||||
|
pending.delete(year);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearDashboardCache() {
|
||||||
|
cache.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,10 @@ import type {
|
|||||||
StandardRuleUpdate,
|
StandardRuleUpdate,
|
||||||
} from "@/types/standards";
|
} from "@/types/standards";
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 30_000;
|
||||||
|
const cache = new Map<string, { data: StandardRuleListData; at: number }>();
|
||||||
|
const pending = new Map<string, Promise<StandardRuleListData>>();
|
||||||
|
|
||||||
export async function getStandards(params: {
|
export async function getStandards(params: {
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
@ -18,9 +22,27 @@ export async function getStandards(params: {
|
|||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
}): Promise<StandardRuleListData> {
|
}): Promise<StandardRuleListData> {
|
||||||
|
const key = JSON.stringify(params);
|
||||||
|
const cached = cache.get(key);
|
||||||
|
if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.data;
|
||||||
|
const running = pending.get(key);
|
||||||
|
if (running) return running;
|
||||||
|
const request = (async () => {
|
||||||
const response = await fetchStandards(params);
|
const response = await fetchStandards(params);
|
||||||
if (!response.data.data) throw new Error("标准库响应为空");
|
if (!response.data.data) throw new Error("标准库响应为空");
|
||||||
|
cache.set(key, { data: response.data.data, at: Date.now() });
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
|
})();
|
||||||
|
pending.set(key, request);
|
||||||
|
try {
|
||||||
|
return await request;
|
||||||
|
} finally {
|
||||||
|
pending.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearStandardsCache() {
|
||||||
|
cache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getStandard(ruleId: number): Promise<StandardRuleDetail> {
|
export async function getStandard(ruleId: number): Promise<StandardRuleDetail> {
|
||||||
@ -34,6 +56,7 @@ export async function addStandard(
|
|||||||
): Promise<StandardRuleDetail> {
|
): Promise<StandardRuleDetail> {
|
||||||
const response = await createStandard(payload);
|
const response = await createStandard(payload);
|
||||||
if (!response.data.data) throw new Error("新增标准响应为空");
|
if (!response.data.data) throw new Error("新增标准响应为空");
|
||||||
|
clearStandardsCache();
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -43,5 +66,6 @@ export async function editStandard(
|
|||||||
): Promise<StandardRuleDetail> {
|
): Promise<StandardRuleDetail> {
|
||||||
const response = await updateStandard(ruleId, payload);
|
const response = await updateStandard(ruleId, payload);
|
||||||
if (!response.data.data) throw new Error("修改标准响应为空");
|
if (!response.data.data) throw new Error("修改标准响应为空");
|
||||||
|
clearStandardsCache();
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,18 +19,37 @@ export const useConversationStore = defineStore("conversation", () => {
|
|||||||
const sending = ref(false);
|
const sending = ref(false);
|
||||||
const loadingOlder = ref(false);
|
const loadingOlder = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
|
let listRequest: Promise<void> | null = null;
|
||||||
|
let listRequestSearch: string | undefined;
|
||||||
|
let listLoadedAt = 0;
|
||||||
|
|
||||||
async function loadList(search?: string) {
|
async function loadList(search?: string) {
|
||||||
|
const normalizedSearch = search?.trim() || undefined;
|
||||||
|
if (listRequest && listRequestSearch === normalizedSearch)
|
||||||
|
return listRequest;
|
||||||
|
if (
|
||||||
|
!normalizedSearch &&
|
||||||
|
items.value.length &&
|
||||||
|
Date.now() - listLoadedAt < 30_000
|
||||||
|
)
|
||||||
|
return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
listRequestSearch = normalizedSearch;
|
||||||
|
listRequest = (async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetchConversations(50, search);
|
const response = await fetchConversations(50, normalizedSearch);
|
||||||
items.value = response.data.data ?? [];
|
items.value = response.data.data ?? [];
|
||||||
|
if (!normalizedSearch) listLoadedAt = Date.now();
|
||||||
} catch {
|
} catch {
|
||||||
error.value = "历史对话加载失败";
|
error.value = "历史对话加载失败";
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
listRequest = null;
|
||||||
|
listRequestSearch = undefined;
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
return listRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function open(id: number) {
|
async function open(id: number) {
|
||||||
|
|||||||
@ -39,10 +39,17 @@ export interface RiskAssessmentResult {
|
|||||||
modelVersion: string;
|
modelVersion: string;
|
||||||
factors: RiskFactorResult[];
|
factors: RiskFactorResult[];
|
||||||
similarCases?: Array<{
|
similarCases?: Array<{
|
||||||
|
caseId?: number | null;
|
||||||
date?: string | null;
|
date?: string | null;
|
||||||
product?: string | null;
|
product?: string | null;
|
||||||
|
category?: string | null;
|
||||||
|
material?: string | null;
|
||||||
problem?: string | null;
|
problem?: string | null;
|
||||||
|
rootCause?: string | null;
|
||||||
solution?: string | null;
|
solution?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
defectLevel?: string | null;
|
||||||
|
result?: string | null;
|
||||||
similarity?: number | null;
|
similarity?: number | null;
|
||||||
}>;
|
}>;
|
||||||
aiSuggestions?: string[];
|
aiSuggestions?: string[];
|
||||||
|
|||||||
44
src/types/supplierProfile.ts
Normal file
44
src/types/supplierProfile.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
export interface SupplierCategoryBreakdown {
|
||||||
|
category: string;
|
||||||
|
isUnclassified: boolean;
|
||||||
|
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;
|
||||||
|
supplierBaseScore: number | null;
|
||||||
|
supplierScore: number | null;
|
||||||
|
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