统一供应商画像展示并优化页面加载速度
供应商画像直接展示后端统一计算的基础分、频次风险系数和供应商得分;对齐对话卡片数据;未归类品类固定灰色展示并保留待补标记录;隐藏无推荐数据的代工厂模块;增加页面缓存、资源预加载、接口缓存和重复请求去重。
This commit is contained in:
parent
904198ae8b
commit
74a1c9d4bc
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -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 response = await fetchDashboard(year);
|
const cached = cache.get(year);
|
||||||
if (!response.data.data) throw new Error("Dashboard response is empty");
|
if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.data;
|
||||||
return response.data.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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 response = await fetchStandards(params);
|
const key = JSON.stringify(params);
|
||||||
if (!response.data.data) throw new Error("标准库响应为空");
|
const cached = cache.get(key);
|
||||||
return response.data.data;
|
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> {
|
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;
|
||||||
try {
|
listRequestSearch = normalizedSearch;
|
||||||
const response = await fetchConversations(50, search);
|
listRequest = (async () => {
|
||||||
items.value = response.data.data ?? [];
|
try {
|
||||||
} catch {
|
const response = await fetchConversations(50, normalizedSearch);
|
||||||
error.value = "历史对话加载失败";
|
items.value = response.data.data ?? [];
|
||||||
} finally {
|
if (!normalizedSearch) listLoadedAt = Date.now();
|
||||||
loading.value = false;
|
} catch {
|
||||||
}
|
error.value = "历史对话加载失败";
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
listRequest = null;
|
||||||
|
listRequestSearch = undefined;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return listRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function open(id: number) {
|
async function open(id: number) {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user