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

供应商画像直接展示后端统一计算的基础分、频次风险系数和供应商得分;对齐对话卡片数据;未归类品类固定灰色展示并保留待补标记录;隐藏无推荐数据的代工厂模块;增加页面缓存、资源预加载、接口缓存和重复请求去重。
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 { SupplierProfile } from "@/types/supplierProfile";
export function fetchSupplierProfile(name: string) {
return http.get<ApiResponse<SupplierProfile>>(
`/v1/suppliers/${encodeURIComponent(name)}`,
);
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();
}

View File

@ -1,9 +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 }
export function fetchSupplierOptions(keyword?: string) {
return http.get<ApiResponse<SupplierListData>>("/v1/suppliers", { params: { keyword, limit: 8 } });
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;
}

View File

@ -26,6 +26,8 @@ interface CardData extends AssistantResponseCard {
anomalyCount?: number;
cooperationCount?: number | null;
frequencyCoefficient?: number;
supplierBaseScore?: number | null;
supplierScore?: number | null;
frequencyBasis?: string;
dataAvailable?: boolean;
tier?: string;
@ -119,6 +121,10 @@ function formatCount(value: unknown) {
<b>×{{ data.frequencyCoefficient?.toFixed(2) ?? "1.00" }}</b
><span>频次系数</span>
</div>
<div v-if="data.supplierScore != null">
<b>{{ Math.round(data.supplierScore) }} 分</b
><span>供应商得分 S供</span>
</div>
</div>
<div class="profile-line">
<span>供应商层级</span><strong>{{ data.tier }}</strong>

View File

@ -20,6 +20,22 @@ const visibleNavigation = computed(() =>
(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>
<template>
@ -52,6 +68,8 @@ const visibleNavigation = computed(() =>
(item === '管理' && route.name === 'review') ||
(item === '对话' && route.name === 'assessment'),
}"
@mouseenter="prefetch(item)"
@focus="prefetch(item)"
>
{{ item }}
</RouterLink>

View File

@ -53,7 +53,23 @@ function canReview() {
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() {
conversationStore.reset();

View File

@ -17,7 +17,11 @@ onMounted(() => appStore.refreshHealth());
<div class="app-shell__body">
<AppSidebar />
<main class="app-content">
<RouterView />
<RouterView v-slot="{ Component }">
<KeepAlive :max="8">
<component :is="Component" />
</KeepAlive>
</RouterView>
</main>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,31 @@
import { fetchDashboard } from "@/api/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(
year: DashboardYear,
): Promise<DashboardData> {
const response = await fetchDashboard(year);
if (!response.data.data) throw new Error("Dashboard response is empty");
return response.data.data;
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);
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;
})();
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,
} 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: {
keyword?: string;
category?: string;
@ -18,9 +22,27 @@ export async function getStandards(params: {
page?: number;
pageSize?: number;
}): Promise<StandardRuleListData> {
const response = await fetchStandards(params);
if (!response.data.data) throw new Error("标准库响应为空");
return response.data.data;
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);
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 await request;
} finally {
pending.delete(key);
}
}
export function clearStandardsCache() {
cache.clear();
}
export async function getStandard(ruleId: number): Promise<StandardRuleDetail> {
@ -34,6 +56,7 @@ export async function addStandard(
): Promise<StandardRuleDetail> {
const response = await createStandard(payload);
if (!response.data.data) throw new Error("新增标准响应为空");
clearStandardsCache();
return response.data.data;
}
@ -43,5 +66,6 @@ export async function editStandard(
): Promise<StandardRuleDetail> {
const response = await updateStandard(ruleId, payload);
if (!response.data.data) throw new Error("修改标准响应为空");
clearStandardsCache();
return response.data.data;
}

View File

@ -19,18 +19,37 @@ export const useConversationStore = defineStore("conversation", () => {
const sending = ref(false);
const loadingOlder = ref(false);
const error = ref<string | null>(null);
let listRequest: Promise<void> | null = null;
let listRequestSearch: string | undefined;
let listLoadedAt = 0;
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;
error.value = null;
try {
const response = await fetchConversations(50, search);
items.value = response.data.data ?? [];
} catch {
error.value = "历史对话加载失败";
} finally {
loading.value = false;
}
listRequestSearch = normalizedSearch;
listRequest = (async () => {
try {
const response = await fetchConversations(50, normalizedSearch);
items.value = response.data.data ?? [];
if (!normalizedSearch) listLoadedAt = Date.now();
} catch {
error.value = "历史对话加载失败";
} finally {
loading.value = false;
listRequest = null;
listRequestSearch = undefined;
}
})();
return listRequest;
}
async function open(id: number) {

View File

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