import { useMemo, useRef, useState } from "react"; import { Download, TrendingDown, TrendingUp } from "lucide-react"; import { useNavigate, useSearchParams } from "react-router"; import { Button } from "~/components/ui/button"; import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog"; import { Input } from "~/components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table"; import { exportRowsToExcel } from "~/lib/exportExcel"; import { CategoryCardRail } from "./CategoryCardRail"; import { FilterSelect, GradeBadge, MetricLineChart, OperationsPageHeader } from "./OperationsUi"; import { useOperationsData } from "./OperationsDataContext"; import { MODEL_CATEGORIES, MONITOR_MONTHS, abnormalLevelOf, average, averageIterationCycle, categoryName, categoryTrend, gradeOf, latestIterationDate, modelIterationCycleMonths, monthsBetween, type ModelCategoryId, type ModelGrade, type ModelRecord, } from "./modelData"; type CategoryCardItem = { id: string; name: string; models: ModelRecord[]; banks: number; versions: number; latestIteration: string; averageCycle: number | null; averageKs: number; averagePsi: number; gradeCounts: Record; }; function isCategoryId(value: string | null): value is ModelCategoryId { return MODEL_CATEGORIES.some((category) => category.id === value); } function DeviationTable({ metric, models, averageValue, onOpenDetail }: { metric: "KS" | "PSI"; models: ModelRecord[]; averageValue: number; onOpenDetail: (modelId: string) => void; }) { const isPsi = metric === "PSI"; const rows = [...models] .filter((model) => (isPsi ? model.psi > averageValue : model.ks < averageValue)) .sort((left, right) => (isPsi ? right.psi - left.psi : left.ks - right.ks)); return ( {isPsi ? : } {metric} {isPsi ? "高于" : "低于"}平均值 平均 {averageValue.toFixed(2)}% · {rows.length} 条 银行 / 模型当月 {metric}等级 / 月份最近处理上次处理建议{rows.length ? rows.map((model) => { const value = isPsi ? model.psi : model.ks; return {model.bank}{model.name} · {model.version}{value.toFixed(2)}%{isPsi ? "↑" : "↓"} {Math.abs(value - averageValue).toFixed(2)}pp onOpenDetail(model.modelId)} />2026-07{model.processedAt ?? "未处理"}{model.previousAdvice}; }) : 当前没有偏离平均值的模型}
); } export default function ModelOverviewPage() { const navigate = useNavigate(); const { models } = useOperationsData(); const [searchParams, setSearchParams] = useSearchParams(); const trendRef = useRef(null); const initialCategory = isCategoryId(searchParams.get("category")) ? searchParams.get("category") as ModelCategoryId : "std"; const [category, setCategory] = useState(initialCategory); const [range, setRange] = useState("6"); const [fromMonth, setFromMonth] = useState("2026-02"); const [toMonth, setToMonth] = useState("2026-07"); const [drilldown, setDrilldown] = useState<{ category: ModelCategoryId; grade: ModelGrade; models: ModelRecord[] } | null>(null); const months = range === "custom" ? monthsBetween(fromMonth, toMonth) : [...MONITOR_MONTHS]; const selectedName = categoryName(category); const selectedModels = models.filter((model) => model.category === category && model.status !== "下线"); const averageKs = average(selectedModels.map((model) => model.ks)); const averagePsi = average(selectedModels.map((model) => model.psi)); const ksSeries = categoryTrend(category, "ks", months, models); const psiSeries = categoryTrend(category, "psi", months, models); const categoryCards = useMemo(() => { const realCards = MODEL_CATEGORIES.map((item) => { const categoryModels = models.filter((model) => model.category === item.id && model.status !== "下线"); const gradeCounts = categoryModels.reduce>((result, model) => ({ ...result, [gradeOf(model)]: result[gradeOf(model)] + 1 }), { A: 0, B: 0, C: 0 }); return { ...item, models: categoryModels, banks: new Set(categoryModels.map((model) => model.bank)).size, versions: new Set(categoryModels.map((model) => model.version)).size, latestIteration: latestIterationDate(categoryModels), averageCycle: averageIterationCycle(categoryModels), averageKs: average(categoryModels.map((model) => model.ks)), averagePsi: average(categoryModels.map((model) => model.psi)), gradeCounts }; }); return realCards; }, [models]); const selectCategory = (value: ModelCategoryId, scroll = false) => { setCategory(value); setSearchParams({ category: value }); if (scroll) requestAnimationFrame(() => trendRef.current?.scrollIntoView({ behavior: "smooth", block: "start" })); }; const openGrade = (item: CategoryCardItem, grade: ModelGrade) => { setDrilldown({ category: item.id as ModelCategoryId, grade, models: item.models.filter((model) => gradeOf(model) === grade) }); }; return (
{categoryCards.map((item) => { const total = item.gradeCounts.A + item.gradeCounts.B + item.gradeCounts.C || 1; const selected = item.id === category; const activate = () => selectCategory(item.id as ModelCategoryId, true); return ( { if (event.key === "Enter" || event.key === " ") activate(); }}> {item.name}全部银行
{[["银行数", item.banks], ["模型数", item.models.length], ["版本数", item.versions]].map(([label, value]) =>
{label}
{value}
)}
最近迭代日期
{item.latestIteration}
平均迭代周期
{item.averageCycle === null ? "—" : `${item.averageCycle.toFixed(1)}月`}
平均 KS
{item.averageKs.toFixed(1)}%
平均 PSI
{item.averagePsi.toFixed(1)}%
event.stopPropagation()}>{(["A", "B", "C"] as ModelGrade[]).map((grade) => openGrade(item, grade)} />)}
); })}
指标趋势全部银行口径;支持按模型大类与时间范围查看 ({ label: item.name, value: item.id }))} onChange={(value) => { if (isCategoryId(value)) selectCategory(value); }} />{range === "custom" && <>}
navigate(`/operations/monitoring/${modelId}`)} /> navigate(`/operations/monitoring/${modelId}`)} />
平均 KS 趋势{selectedName} · {months[0]} 至 {months.at(-1)} · 最新一期 {averageKs.toFixed(2)}%平均 PSI 趋势{selectedName} · {months[0]} 至 {months.at(-1)} · 最新一期 {averagePsi.toFixed(2)}%
{ if (!open) setDrilldown(null); }}> {drilldown ? `${categoryName(drilldown.category)} · ${drilldown.grade} 等级模型` : "等级模型"}共 {new Set(drilldown?.models.map((model) => model.bank)).size} 家银行、{drilldown?.models.length ?? 0} 个模型;最近迭代日期和平均迭代周期按模型展示。 银行模型ID最近迭代日期平均迭代周期排序性KSPSIKS环比降幅异常等级操作{drilldown?.models.length ? drilldown.models.map((model) => { const cycle = modelIterationCycleMonths(model); return {model.bank}{model.modelId}{model.iteratedAt}{cycle === null ? "—" : `${cycle} 个月`}{model.ranking}{model.ks.toFixed(2)}%{model.psi.toFixed(2)}%{model.ksDrop.toFixed(2)}%{abnormalLevelOf(model)}; }) : 该等级下暂无模型}
{drilldown?.models.length ? : null}
); }