diff --git a/src/api/monitoring.ts b/src/api/monitoring.ts new file mode 100644 index 0000000..534ec03 --- /dev/null +++ b/src/api/monitoring.ts @@ -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, + }); +} diff --git a/src/api/supplierProfile.ts b/src/api/supplierProfile.ts new file mode 100644 index 0000000..b367ea6 --- /dev/null +++ b/src/api/supplierProfile.ts @@ -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>( + `/v1/suppliers/${encodeURIComponent(name)}`, + ); +} diff --git a/src/api/suppliers.ts b/src/api/suppliers.ts new file mode 100644 index 0000000..5d707a5 --- /dev/null +++ b/src/api/suppliers.ts @@ -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>("/v1/suppliers", { params: { keyword, limit: 8 } }); +} diff --git a/src/components/assistant/AssistantResponseCard.vue b/src/components/assistant/AssistantResponseCard.vue index 2742822..e1be85c 100644 --- a/src/components/assistant/AssistantResponseCard.vue +++ b/src/components/assistant/AssistantResponseCard.vue @@ -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) { > 标准库规则匹配 · 待品控确认 + 置信度:{{ data.confidence || "中" }} @@ -284,6 +306,20 @@ function formatCount(value: unknown) { + +

{{ feedback }}

@@ -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); diff --git a/src/components/assistant/RiskAnalysisCard.vue b/src/components/assistant/RiskAnalysisCard.vue index 4a97cc8..9454001 100644 --- a/src/components/assistant/RiskAnalysisCard.vue +++ b/src/components/assistant/RiskAnalysisCard.vue @@ -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[number]; const emit = defineEmits<{ generateReport: [] }>(); const expanded = ref(true); +const evidenceExpanded = ref(true); const feedback = ref(""); +const selectedCase = ref(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(() => (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, + }); +}

{{ @@ -191,16 +258,22 @@ const stars = computed(() => {

标准判定 · 双引擎

+

+ 命中判定标准库 {{ result.standardJudgment.length }} 条 · 覆盖 + {{ standardCategories.join(" / ") }} · 来源:《标准文件.xlsx》219 条 +

暂无相关标准

-
{ {{ standard.status }} -
+
- 双引擎融合:历史异常经验引擎提供风险优先级,标准库提供可执行的检测阈值与验证任务。 + ↻ + 双引擎融合:历史数据(经验引擎)提供风险优先级与相似案例;标准库(规则引擎)给出量化阈值与检测依据。 + + +
@@ -245,6 +335,54 @@ const stars = computed(() => { > +
+ R = {{ formulaText }}({{ formatRiskLevel(result.level) }}) +
+ + +
+ +
+
+ ① 向量检索新品参数嵌入 → 召回历史案例 → 余弦相似度与业务字段混合排序,返回 + Top {{ result.similarCases?.length ?? 0 }} 条。 +
+
+ ② 相似度加权按相似度和时间衰减因子 D(t) + 对候选案例排序,优先展示高相关案例。 +
+
+ ③ 七维评分{{ formulaText }},映射为{{ + formatRiskLevel(result.level) + }}。 +
+
+ ④ 建议生成聚合相似案例方案,结合标准规则生成 + {{ suggestions.length }} 条可执行建议。 +
+
@@ -263,6 +401,7 @@ const stars = computed(() => { :key="`${item.date}-${index}`" class="case-row" type="button" + @click="openCase(item)" > TOP {{ index + 1 }}
@@ -296,14 +435,90 @@ const stars = computed(() => { > 生成报告 - - + +

{{ feedback }}

+
+ +
@@ -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 { diff --git a/src/components/layout/AppSidebar.vue b/src/components/layout/AppSidebar.vue index 936ae5e..614cefd 100644 --- a/src/components/layout/AppSidebar.vue +++ b/src/components/layout/AppSidebar.vue @@ -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, diff --git a/src/pages/RiskAssistantPage.vue b/src/pages/RiskAssistantPage.vue index 808c87b..b4cae0c 100644 --- a/src/pages/RiskAssistantPage.vue +++ b/src/pages/RiskAssistantPage.vue @@ -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; diff --git a/src/pages/StandardsPage.vue b/src/pages/StandardsPage.vue index 620af82..479c82d 100644 --- a/src/pages/StandardsPage.vue +++ b/src/pages/StandardsPage.vue @@ -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 = { }; 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); }); diff --git a/src/pages/SupplierProfilePage.vue b/src/pages/SupplierProfilePage.vue new file mode 100644 index 0000000..1582826 --- /dev/null +++ b/src/pages/SupplierProfilePage.vue @@ -0,0 +1,463 @@ + + + + + diff --git a/src/router/routes.ts b/src/router/routes.ts index d0500b1..f4634f4 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -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", diff --git a/src/types/risk.ts b/src/types/risk.ts index cc7b92b..13d9681 100644 --- a/src/types/risk.ts +++ b/src/types/risk.ts @@ -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[]; diff --git a/src/types/supplierProfile.ts b/src/types/supplierProfile.ts new file mode 100644 index 0000000..ce3f875 --- /dev/null +++ b/src/types/supplierProfile.ts @@ -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; +}