统一供应商画像展示并优化页面加载速度

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

View File

@ -2,8 +2,37 @@ import { http } from "@/api/http";
import type { ApiResponse } from "@/types/api"; import type { ApiResponse } from "@/types/api";
import type { SupplierProfile } from "@/types/supplierProfile"; import type { SupplierProfile } from "@/types/supplierProfile";
export function fetchSupplierProfile(name: string) { const CACHE_TTL_MS = 30_000;
return http.get<ApiResponse<SupplierProfile>>( const cache = new Map<string, { data: SupplierProfile; at: number }>();
`/v1/suppliers/${encodeURIComponent(name)}`, 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();
} }

View File

@ -1,9 +1,44 @@
import { http } from "@/api/http"; import { http } from "@/api/http";
import type { ApiResponse } from "@/types/api"; import type { ApiResponse } from "@/types/api";
export interface SupplierOption { name: string; anomalyCount: number; tier: string } export interface SupplierOption {
export interface SupplierListData { items: SupplierOption[]; defaultSupplier: string | null } name: string;
anomalyCount: number;
export function fetchSupplierOptions(keyword?: string) { tier: string;
return http.get<ApiResponse<SupplierListData>>("/v1/suppliers", { params: { keyword, limit: 8 } }); }
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;
} }

View File

@ -26,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;
@ -119,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>

View File

@ -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>

View File

@ -53,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();

View File

@ -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>

View File

@ -1,13 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import {
ArrowLeft,
BarChart3,
Building2,
CalendarDays,
Factory,
RefreshCw,
ShieldCheck,
} from "@lucide/vue";
import { computed, onMounted, ref, watch } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
@ -23,20 +14,74 @@ const profile = ref<SupplierProfile | null>(null);
const loading = ref(false); const loading = ref(false);
const error = ref(""); const error = ref("");
const maxTrend = computed(() => const maxCategoryCount = computed(() =>
Math.max( Math.max(
1, 1,
...(profile.value?.monthlyTrend.map((item) => item.count) ?? [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 tierClass = computed(() => {
if (profile.value?.tier.startsWith("C")) return "tier-c"; const tier = profile.value?.tier ?? "S级";
if (profile.value?.tier.startsWith("B")) return "tier-b"; if (tier === "新供应商") return "tier-C";
if (profile.value?.tier.startsWith("A")) return "tier-a"; return tier.startsWith("C")
return "tier-s"; ? "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() { async function loadOptions() {
try {
const response = await fetchSupplierOptions(); const response = await fetchSupplierOptions();
supplierNames.value = supplierNames.value =
response.data.data?.items.map((item) => item.name) ?? []; response.data.data?.items.map((item) => item.name) ?? [];
@ -45,14 +90,17 @@ async function loadOptions() {
? route.query.supplier ? route.query.supplier
: (response.data.data?.defaultSupplier ?? supplierNames.value[0] ?? ""); : (response.data.data?.defaultSupplier ?? supplierNames.value[0] ?? "");
if (selected.value) await loadProfile(); if (selected.value) await loadProfile();
} catch {
error.value = "供应商列表加载失败,请重试。";
}
} }
async function loadProfile() { async function loadProfile() {
if (!selected.value) return; if (!selected.value) return;
loading.value = true; loading.value = true;
error.value = ""; error.value = "";
try { try {
const response = await fetchSupplierProfile(selected.value); profile.value =
profile.value = response.data.data ?? null; (await fetchSupplierProfile(selected.value)).data.data ?? null;
} catch { } catch {
profile.value = null; profile.value = null;
error.value = "供应商画像加载失败,请重试。"; error.value = "供应商画像加载失败,请重试。";
@ -81,16 +129,12 @@ watch(
<template> <template>
<div class="supplier-page"> <div class="supplier-page">
<div class="supplier-inner">
<header class="supplier-head"> <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> <h1>供应商深度画像</h1>
</div>
<select <select
v-model="selected" v-model="selected"
class="supplier-select"
aria-label="选择供应商" aria-label="选择供应商"
@change="chooseSupplier" @change="chooseSupplier"
> >
@ -99,365 +143,575 @@ watch(
</option> </option>
</select> </select>
<span v-if="profile" class="tier-badge" :class="tierClass">{{ <span v-if="profile" class="tier-badge" :class="tierClass">{{
profile.tier tierLabel
}}</span> }}</span>
<span class="version-note"
>v3.0 · N 口径 + 基础分/α 可推导(对齐需求细化表 4.11)</span
>
</header> </header>
<p v-if="error" class="page-error">{{ error }}</p> <p v-if="error" class="page-error">{{ error }}</p>
<div v-if="loading" class="loading-state"> <div v-if="loading" class="loading-state">正在加载供应商画像…</div>
<RefreshCw class="spin" :size="18" />正在加载供应商画像…
</div>
<template v-else-if="profile"> <template v-else-if="profile">
<section class="stat-grid"> <section class="stat-row">
<article> <div class="stat-card clickable">
<Building2 :size="17" /><b>{{ profile.metric.anomalyTotal }}</b <div class="stat-value blue">{{ profile.metric.anomalyTotal }}</div>
><small>异常总数 N</small> <div class="stat-label">近3年异常</div>
</article> </div>
<article> <div class="stat-card clickable">
<CalendarDays :size="17" /><b>{{ <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 ?? "暂无" profile.metric.cooperationTotal ?? "暂无"
}}</b }}</b
><small>采购订单数</small> >。
</article> </div>
<article> <table class="prototype-table">
<BarChart3 :size="17" /><b <thead>
>×{{ profile.metric.frequencyAlpha.toFixed(2) }}</b <tr>
><small>频次风险系数 α</small> <th>层级</th>
</article> <th>定义</th>
<article> <th>判定条件(按 N)</th>
<ShieldCheck :size="17" /><b>{{ <th>建议动作</th>
profile.metric.riskScore ?? "暂无" </tr>
}}</b </thead>
><small>七维综合评分 R</small> <tbody>
</article> <tr>
<article> <td><span class="tier-badge tier-S">S级·战略</span></td>
<Factory :size="17" /><b>{{ profile.metric.mainCategoryCount }}</b <td>低异常、无 A 级缺陷</td>
><small>主要品类数</small> <td>N&lt;20 且无 A 级缺陷</td>
</article> <td>战略绑定,优先排产</td>
</section> </tr>
<section class="panel model-panel"> <tr>
<h2>📐 合作频次 × 风险量化模型</h2> <td><span class="tier-badge tier-A">A级·优质</span></td>
<p> <td>中低异常</td>
α = 1.0 +(采购订单数 ÷ 100)× 0.3(上限 1.5);供应商得分 S供 = <td>20≤N≤60 且 A/B 级占比&lt;30%</td>
基础分 × α(封顶100)。 <td>常规合作,季度复核</td>
</p> </tr>
<div class="basis"> <tr>
{{ <td><span class="tier-badge tier-B">B级·关注</span></td>
profile.metric.cooperationCountBasis <td>异常偏高</td>
}}。异常数仍按异常案例库统计,供应商层级按异常库口径判定。 <td>60&lt;N≤120 或 A/B 级占比 30%~50%</td>
<td>加强巡检 + 备选供应商</td>
</tr>
<tr>
<td><span class="tier-badge tier-C">C级·高风险</span></td>
<td>高异常 / 新供应商</td>
<td>N&gt;120 或存在 A 级缺陷</td>
<td>驻厂 / 备选 / 限制新品使用</td>
</tr>
</tbody>
</table>
<div class="table-gap"></div>
<table class="prototype-table">
<thead>
<tr>
<th>近 3 年异常数 N</th>
<th>基础分</th>
<th>档位含义</th>
<th>在库示例</th>
</tr>
</thead>
<tbody>
<tr>
<td>0(新供应商)</td>
<td><b>70</b></td>
<td>中性偏谨慎(50 中性 + 20 无负面历史加成)</td>
<td>德盛</td>
</tr>
<tr>
<td>1 ~ 20</td>
<td><b>90</b></td>
<td>优秀</td>
<td>华彩(12)</td>
</tr>
<tr>
<td>21 ~ 60</td>
<td><b>75</b></td>
<td>良好</td>
<td>—</td>
</tr>
<tr>
<td>61 ~ 100</td>
<td><b>65</b></td>
<td>中等</td>
<td>瑞沣(70)、博元(78)、晶品(81)</td>
</tr>
<tr>
<td>101 ~ 150</td>
<td><b>45</b></td>
<td>较差</td>
<td>佳亿(150)、祥印(148)</td>
</tr>
<tr>
<td>&gt; 150</td>
<td><b>30</b></td>
<td>极差</td>
<td>—</td>
</tr>
</tbody>
</table>
<div class="panel-note">
修正项(4.11-3):A 级缺陷每条 −5(上限 −15);近 6 月趋势显著上升
−10 / 平稳 0 / 下降 +5
</div> </div>
</section> </section>
<section class="panel">
<h2>📊 品类维度异常分布</h2> <section class="supplier-panel">
<div class="category-list"> <h3>📊 品类维度异常分布 <span class="new-tag">新</span></h3>
<div v-if="categoryBars.length">
<div <div
v-for="item in profile.categoryBreakdown" v-for="item in categoryBars"
:key="item.category" :key="item.category"
class="category-row" 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
> >
<span>{{ item.category }}</span>
<div>
<i
:style="{
width: `${Math.min(100, (item.anomalyCount / Math.max(1, profile.metric.anomalyTotal)) * 100)}%`,
}"
></i>
</div> </div>
<b>{{ item.anomalyCount }}</b
><small>{{ item.topProblem || "暂无高频问题" }}</small>
</div> </div>
</div> </div>
<p v-else class="empty-panel">暂无品类分布数据</p>
<div class="panel-note category-note">
灰色“未归类(待补标)”仅表示缺少品类标签,不参与真实品类风险排行;补标后将自动归入对应品类。
</div>
</section> </section>
<section class="panel">
<h2>📈 近六个月异常趋势</h2> <section v-if="factoryRecommendations.length" class="supplier-panel">
<div class="trend"> <h3>🏭 推荐代工厂配对</h3>
<div <table class="prototype-table">
v-for="item in profile.monthlyTrend" <thead>
:key="item.month" <tr>
class="trend-col" <th>品类</th>
> <th>首选代工</th>
<span>{{ item.count }}</span <th>备选代工</th>
><i <th>理由</th>
:style="{ </tr>
height: `${Math.max(4, (item.count / maxTrend) * 100)}%`, </thead>
}" <tbody>
></i <tr v-for="item in factoryRecommendations" :key="item.category">
><small>{{ item.month.slice(5) }}月</small> <td>{{ item.category }}</td>
</div> <td>{{ item.preferredFactory || "—" }}</td>
</div> <td>{{ item.alternativeFactory || "—" }}</td>
<td>{{ item.reason }}</td>
</tr>
</tbody>
</table>
</section> </section>
<section class="panel">
<h2>📅 近期异常记录</h2> <section class="supplier-panel">
<div v-if="profile.recentRecords.length" class="records"> <h3>📅 近期合作记录</h3>
<div <template v-if="recentRecords.length"
v-for="item in profile.recentRecords" ><div
v-for="item in recentRecords"
:key="item.caseId" :key="item.caseId"
class="record" class="cooperation-item"
> >
<b>{{ item.month || "未知日期" }}</b <span class="cooperation-rank">{{
><span item.month || "未知日期"
}}</span
><span class="cooperation-text"
>{{ item.product || "未命名产品" }} → >{{ item.product || "未命名产品" }} →
{{ item.anomaly || "异常描述未填写" }}</span {{ item.anomaly || "异常描述未填写"
><em>{{ item.defectLevel || "未定级" }}</em> }}<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> </div>
<p v-else class="empty">暂无近期异常记录</p> <div class="panel-note">
录入的新异常经审核入库后,将自动更新本页合作记录与风险评分
</div>
</section> </section>
</template> </template>
</div> </div>
</div>
</template> </template>
<style scoped> <style scoped>
.supplier-page { .supplier-page {
flex: 1; flex: 1;
min-width: 0;
overflow-y: auto; overflow-y: auto;
padding: 24px 34px 48px; padding: 24px 28px 40px;
background: #f5f7fb; background: #f7f8fa;
}
.supplier-inner {
width: min(960px, 100%);
margin: 0 auto;
} }
.supplier-head { .supplier-head {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
gap: 14px; gap: 14px;
max-width: 1120px; margin-bottom: 24px;
margin: 0 auto 20px;
}
.supplier-head h1,
.supplier-head p {
margin: 0;
} }
.supplier-head h1 { .supplier-head h1 {
margin: 0;
font-size: 22px; font-size: 22px;
font-weight: 650;
} }
.eyebrow { .supplier-select {
color: #2563eb; padding: 7px 14px;
font-size: 10px; border: 1px solid #e4e4e2;
font-weight: 750;
}
.supplier-head select {
margin-left: auto;
min-width: 250px;
border: 1px solid #cbd5e1;
border-radius: 5px; border-radius: 5px;
padding: 9px; outline: none;
background: white; background: #fff;
color: #111;
font: inherit; font: inherit;
font-size: 12px; font-size: 13px;
}
.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; cursor: pointer;
} }
.tier-badge { .version-note {
border-radius: 999px; color: #999;
padding: 5px 10px;
font-size: 11px; 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; font-weight: 700;
white-space: nowrap;
} }
.tier-s { .tier-S {
background: #dcfce7; border-color: #fde68a;
color: #166534;
}
.tier-a {
background: #d1fae5;
color: #065f46;
}
.tier-b {
background: #fef3c7; background: #fef3c7;
color: #92400e; color: #92400e;
} }
.tier-c { .tier-A {
border-color: #bbf7d0;
background: #d1fae5;
color: #065f46;
}
.tier-B {
border-color: #fecaca;
background: #fee2e2; background: #fee2e2;
color: #b91c1c; color: #991b1b;
} }
.stat-grid, .tier-C {
.panel { border-color: #dc2626;
max-width: 1120px; background: #dc2626;
margin: 0 auto; color: #fff;
} }
.stat-grid { .stat-row {
display: grid; display: grid;
grid-template-columns: repeat(5, 1fr); grid-template-columns: repeat(6, 1fr);
gap: 10px; gap: 10px;
margin-bottom: 20px;
} }
.stat-grid article { .stat-card {
display: grid; min-width: 0;
grid-template-columns: auto 1fr;
gap: 3px 8px;
align-items: center;
border: 1px solid #dbe3ed;
border-radius: 7px;
padding: 14px; padding: 14px;
background: white; border: 1px solid #e4e4e2;
border-radius: 8px;
background: #fff;
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
text-align: center;
} }
.stat-grid article svg { .stat-card.clickable {
grid-row: 1/3; 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; color: #2563eb;
} }
.stat-grid b { .stat-value.red {
font-size: 20px; color: #dc2626;
} }
.stat-grid small { .stat-value.purple {
color: #64748b; color: #7c3aed;
font-size: 10px;
} }
.panel { .stat-value.amber {
margin-top: 14px; color: #d97706;
border: 1px solid #dbe3ed; }
border-radius: 8px; .stat-value.green {
color: #16a34a;
}
.stat-label {
margin-top: 4px;
color: #666;
font-size: 11px;
}
.supplier-panel {
margin-bottom: 14px;
padding: 20px; padding: 20px;
background: white; border: 1px solid #e4e4e2;
border-radius: 8px;
background: #fff;
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
} }
.panel h2 { .supplier-panel h3 {
margin: 0 0 13px; display: flex;
align-items: center;
gap: 8px;
margin: 0 0 14px;
font-size: 14px; font-size: 14px;
font-weight: 600;
} }
.model-panel p { .new-tag {
margin: 0; padding: 1px 5px;
border-radius: 3px;
background: #eff6ff;
color: #2563eb;
font-size: 9px;
font-weight: 700;
}
.model-summary {
margin-bottom: 12px;
padding: 12px; padding: 12px;
border-radius: 5px; border-radius: 5px;
background: #f8fafc; background: #f7f7f5;
color: #334155; color: #666;
font-size: 12px; font-size: 13px;
line-height: 1.8; line-height: 1.9;
} }
.basis { .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; margin-top: 8px;
color: #64748b; color: #999;
font-size: 10px; font-size: 11px;
} }
.category-row { .heat-row {
display: grid; display: flex;
grid-template-columns: 110px minmax(100px, 1fr) 55px;
gap: 10px;
align-items: center; align-items: center;
margin: 10px 0; gap: 10px;
margin-bottom: 6px;
}
.heat-label {
width: 130px;
flex-shrink: 0;
color: #666;
font-size: 12px; 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; text-align: right;
} }
.category-row small { .heat-bar {
grid-column: 1/-1; position: relative;
color: #64748b;
font-size: 10px;
}
.trend {
display: flex; 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; flex: 1;
height: 28px;
align-items: center; align-items: center;
justify-content: end; overflow: hidden;
flex-direction: column; padding: 0 10px;
gap: 3px; border-radius: 5px;
} }
.trend-col span { .heat-fill {
font-size: 10px; position: absolute;
color: #64748b; inset: 0 auto 0 0;
border-radius: 5px;
} }
.trend-col i { .heat-text {
width: 65%; position: relative;
min-height: 4px; z-index: 1;
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; font-size: 12px;
font-weight: 600;
} }
.record b { .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; color: #2563eb;
}
.record span {
color: #334155;
}
.record em {
color: #d97706;
font-style: normal;
font-size: 10px; font-size: 10px;
font-weight: 600;
} }
.empty, .cooperation-text {
.page-error, color: #333;
.loading-state { }
max-width: 1120px; .empty-panel,
margin: 50px auto; .loading-state,
.page-error {
margin: 30px 0;
color: #999;
text-align: center; text-align: center;
color: #64748b;
} }
.page-error { .page-error {
color: #b91c1c; color: #dc2626;
} }
.spin { @media (max-width: 900px) {
animation: spin 0.8s linear infinite; .stat-row {
} grid-template-columns: repeat(3, 1fr);
@keyframes spin {
to {
transform: rotate(360deg);
} }
} }
@media (max-width: 800px) { @media (max-width: 640px) {
.supplier-page { .supplier-page {
padding: 18px 12px; padding: 18px 12px 32px;
}
.stat-row {
grid-template-columns: repeat(2, 1fr);
} }
.supplier-head { .supplier-head {
align-items: flex-start; align-items: flex-start;
flex-wrap: wrap;
} }
.supplier-head select { .supplier-select {
order: 4; order: 3;
width: 100%; width: 100%;
margin-left: 0;
} }
.stat-grid { .version-note {
grid-template-columns: repeat(2, 1fr); width: 100%;
} }
.record { .supplier-panel {
grid-template-columns: 1fr; padding: 14px;
overflow-x: auto;
} }
.trend { .prototype-table {
gap: 5px; min-width: 640px;
} }
} .heat-label {
@media print { width: 100px;
.back-button,
.supplier-head select {
display: none;
} }
} }
</style> </style>

View File

@ -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();
} }

View File

@ -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;
} }

View File

@ -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) {

View File

@ -1,5 +1,6 @@
export interface SupplierCategoryBreakdown { export interface SupplierCategoryBreakdown {
category: string; category: string;
isUnclassified: boolean;
anomalyCount: number; anomalyCount: number;
anomalyRate: number | null; anomalyRate: number | null;
topMaterial: string | null; topMaterial: string | null;
@ -23,6 +24,8 @@ export interface SupplierProfile {
anomalyTotal: number; anomalyTotal: number;
anomalyRate: number | null; anomalyRate: number | null;
frequencyAlpha: number; frequencyAlpha: number;
supplierBaseScore: number | null;
supplierScore: number | null;
riskScore: number | null; riskScore: number | null;
mainCategoryCount: number; mainCategoryCount: number;
cooperationCountBasis: string; cooperationCountBasis: string;