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 { AbnormalBadge, FilterSelect, GradeBadge, MetricLineChart, OperationsPageHeader } from "./OperationsUi"; import { useOperationsData } from "./OperationsDataContext"; import { MODEL_CATEGORIES, MONITOR_MONTHS, abnormalLevelOf, average, averageIterationCycle, categoryName, categoryTrend, gradeOf, latestIterationDate, modelIterationCycleMonths, modelsTrend, monthsBetween, type ModelCategoryId, type ModelGrade, type ModelRecord, } from "./modelData"; type BankCategoryCard = { id: string; name: string; models: ModelRecord[]; modelCount: number; versions: number; latestIteration: string; averageCycle: number | null; ownKs: number; ownPsi: number; peerKs: number; peerPsi: number; grades: Record; }; function isCategoryId(value: string | null): value is ModelCategoryId { return MODEL_CATEGORIES.some((category) => category.id === value); } function changeOf(series: number[]): number { if (series.length < 2) return 0; return Number(((series.at(-1) ?? 0) - (series.at(-2) ?? 0)).toFixed(2)); } function TrendSummary({ own, peer }: { own: number[]; peer: number[] }) { const values = [ ["本行均值", `${(own.at(-1) ?? 0).toFixed(2)}%`, "text-primary"], ["本行较上月", `${changeOf(own) >= 0 ? "+" : ""}${changeOf(own).toFixed(2)}pp`, "text-primary"], ["同业均值", `${(peer.at(-1) ?? 0).toFixed(2)}%`, "text-warning"], ["同业较上月", `${changeOf(peer) >= 0 ? "+" : ""}${changeOf(peer).toFixed(2)}pp`, "text-warning"], ]; return
{values.map(([label, value, tone]) =>
{label}{value}
)}
; } function BankDeviationTable({ 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.name}{model.version} · {model.modelId}{value.toFixed(2)}%{isPsi ? "↑" : "↓"} {Math.abs(value - averageValue).toFixed(2)}pp onOpenDetail(model.modelId)} />2026-07{model.processedAt ?? "未处理"}{model.previousAdvice}; }) : 当前没有偏离同业平均值的模型}
); } export default function BankOverviewPage() { const navigate = useNavigate(); const { models } = useOperationsData(); const [searchParams, setSearchParams] = useSearchParams(); const trendRef = useRef(null); const banks = useMemo(() => [...new Set(models.map((model) => model.bank))], [models]); const initialBank = banks.includes(searchParams.get("bank") ?? "") ? searchParams.get("bank") as string : banks[0] ?? ""; const initialCategory = isCategoryId(searchParams.get("category")) ? searchParams.get("category") as ModelCategoryId : "std"; const [bank, setBank] = useState(initialBank); 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 selectedModels = models.filter((model) => model.bank === bank && model.category === category && model.status !== "下线"); const peerModels = models.filter((model) => model.category === category && model.status !== "下线"); const ownKs = average(selectedModels.map((model) => model.ks)); const ownPsi = average(selectedModels.map((model) => model.psi)); const peerKs = average(peerModels.map((model) => model.ks)); const peerPsi = average(peerModels.map((model) => model.psi)); const ownKsSeries = modelsTrend(selectedModels, "ks", months); const ownPsiSeries = modelsTrend(selectedModels, "psi", months); const peerKsSeries = categoryTrend(category, "ks", months, models); const peerPsiSeries = categoryTrend(category, "psi", months, models); const cards = useMemo(() => { const realCards = MODEL_CATEGORIES.map((item) => { const bankModels = models.filter((model) => model.bank === bank && model.category === item.id && model.status !== "下线"); const allPeers = models.filter((model) => model.category === item.id && model.status !== "下线"); const grades = bankModels.reduce>((result, model) => ({ ...result, [gradeOf(model)]: result[gradeOf(model)] + 1 }), { A: 0, B: 0, C: 0 }); return { ...item, models: bankModels, modelCount: bankModels.length, versions: new Set(bankModels.map((model) => model.version)).size, latestIteration: latestIterationDate(bankModels), averageCycle: averageIterationCycle(bankModels), ownKs: average(bankModels.map((model) => model.ks)), ownPsi: average(bankModels.map((model) => model.psi)), peerKs: average(allPeers.map((model) => model.ks)), peerPsi: average(allPeers.map((model) => model.psi)), grades }; }); return realCards; }, [bank, models]); const syncParams = (nextBank: string, nextCategory: ModelCategoryId) => setSearchParams({ bank: nextBank, category: nextCategory }); const selectBank = (value: string) => { setBank(value); syncParams(value, category); }; const selectCategory = (value: ModelCategoryId, scroll = false) => { setCategory(value); syncParams(bank, value); if (scroll) requestAnimationFrame(() => trendRef.current?.scrollIntoView({ behavior: "smooth", block: "start" })); }; const openGrade = (item: BankCategoryCard, grade: ModelGrade) => { const gradeModels = item.models.filter((model) => gradeOf(model) === grade); if (gradeModels.length === 1) return navigate(`/operations/monitoring/${gradeModels[0]?.modelId}`); setDrilldown({ category: item.id as ModelCategoryId, grade, models: gradeModels }); }; return (

选择银行后,卡片展示本行指标及同业对比

{cards.map((item) => { const total = item.grades.A + item.grades.B + item.grades.C || 1; const selected = item.id === category; const hasModels = item.modelCount > 0; const activate = () => selectCategory(item.id as ModelCategoryId, true); return { if (event.key === "Enter" || event.key === " ") activate(); }}>{item.name}银行-{bank || "全部"}
{[["银行数", hasModels ? 1 : 0], ["模型数", item.modelCount], ["版本数", item.versions]].map(([label, value]) =>
{label}
{value}
)}
最近迭代日期
{item.latestIteration}
平均迭代周期
{hasModels && item.averageCycle !== null ? `${item.averageCycle.toFixed(1)}月` : "—"}
本行平均 KS
{hasModels ? `${item.ownKs.toFixed(1)}%` : "—"}
本行平均 PSI
{hasModels ? `${item.ownPsi.toFixed(1)}%` : "—"}
同业平均 KS
{item.peerKs.toFixed(1)}%
同业平均 PSI
{item.peerPsi.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" && <>} {selectedModels.length ? <>
navigate(`/operations/monitoring/${modelId}`)} /> navigate(`/operations/monitoring/${modelId}`)} />
平均 KS 趋势{bank} · {categoryName(category)} · {months[0]} 至 {months.at(-1)}平均 PSI 趋势{bank} · {categoryName(category)} · {months[0]} 至 {months.at(-1)}
:
{bank}当前暂无{categoryName(category)}在用模型,请选择其他大类。
}
{ if (!open) setDrilldown(null); }}>{drilldown ? `${bank} · ${categoryName(drilldown.category)} · ${drilldown.grade} 等级模型` : "等级模型"}共 {drilldown?.models.length ?? 0} 个模型,支持逐行进入监控详情。模型ID最近迭代日期平均迭代周期排序性KSPSIKS环比降幅异常等级操作{drilldown?.models.length ? drilldown.models.map((model) => { const cycle = modelIterationCycleMonths(model); return {model.modelId}{model.iteratedAt}{cycle === null ? "—" : `${cycle} 个月`}{model.ranking}{model.ks.toFixed(2)}%{model.psi.toFixed(2)}%{model.ksDrop.toFixed(2)}%; }) : 该等级下暂无模型}
); }