import { useMemo, useRef, useState } from "react"; import { ArrowRight, Download, TrendingDown, TrendingUp } from "lucide-react"; import { useNavigate, useSearchParams } from "react-router"; import { toast } from "sonner"; 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, categoryName, categoryTrend, gradeOf, latestIterationDate, modelIterationCycleMonths, monthsBetween, type ModelCategoryId, type ModelGrade, type ModelRecord, } from "./modelData"; const CATEGORY_AVERAGE_CYCLE: Record = { std: 9.3, bai: 12, big: 8, afd: 14 }; type CategoryCardItem = { id: string; name: string; models: ModelRecord[]; banks: number; versions: number; latestIteration: string; averageCycle: number; averageKs: number; averagePsi: number; gradeCounts: Record; demo?: boolean; }; 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: CATEGORY_AVERAGE_CYCLE[item.id], averageKs: average(categoryModels.map((model) => model.ks)), averagePsi: average(categoryModels.map((model) => model.psi)), gradeCounts }; }); return [...realCards, { id: "consumer-demo", name: "消费贷评分(示意)", models: [], banks: 5, versions: 4, latestIteration: "2026-07-18", averageCycle: 10.6, averageKs: 39.6, averagePsi: 14.2, gradeCounts: { A: 2, B: 2, C: 1 }, demo: true }]; }, [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) => { if (item.demo) return toast.info("该卡片仅用于展示第 5 个及以上模型大类的纵向滚动效果"); setDrilldown({ category: item.id as ModelCategoryId, grade, models: item.models.filter((model) => gradeOf(model) === grade) }); }; return (
navigate("/operations/monitoring")}>查看监控明细 } /> {categoryCards.map((item) => { const total = item.gradeCounts.A + item.gradeCounts.B + item.gradeCounts.C || 1; const selected = !item.demo && item.id === category; const activate = () => item.demo ? toast.info("扩展示意卡:正式接入第 5 个大类后沿用相同卡片结构") : selectCategory(item.id as ModelCategoryId, true); return ( { if (event.key === "Enter" || event.key === " ") activate(); }}> {item.name}{item.demo ? "扩展示意" : "全部银行"}
{[["银行数", item.banks], ["模型数", item.demo ? 5 : item.models.length], ["版本数", item.versions]].map(([label, value]) =>
{label}
{value}
)}
最近迭代日期
{item.latestIteration}
平均迭代周期
{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}
); }