import { useEffect, useMemo, useState } from "react"; import { ArrowUpDown, ChevronLeft, ChevronRight, Download, RotateCcw } 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table"; import { exportRowsToExcel } from "~/lib/exportExcel"; import { AbnormalBadge, FilterSelect, GradeBadge, MultiSelectFilter, OperationsPageHeader, StatusBadge, } from "./OperationsUi"; import { MONITOR_MONTHS, abnormalLevelOf, abnormalReasonOf, categoryName, gradeOf, monitoringRows, type ModelGrade, type MonitoringRow, } from "./modelData"; import { useOperationsData } from "./OperationsDataContext"; type Filters = { bank: string[]; wuji: string[]; name: string[]; modelId: string[]; month: string[]; status: string[]; abnormal: string[]; grade: string[]; ranking: string[]; ksBand: string[]; psiBand: string[]; dropBand: string[]; }; type ArrayFilterKey = keyof Filters; type SortKey = "bank" | "modelId" | "month" | "ks" | "psi" | "grade"; type SortState = { key: SortKey; direction: "asc" | "desc" }; function defaultFilters(): Filters { return { bank: [], wuji: [], name: [], modelId: [], month: ["2026-07"], status: [], abnormal: [], grade: ["B", "C"], ranking: [], ksBand: [], psiBand: [], dropBand: [], }; } function unique(values: string[]): string[] { return [...new Set(values)].sort((left, right) => left.localeCompare(right, "zh-CN")); } function parseValues(params: URLSearchParams, key: string, fallback: string[] = []): string[] { const values = params.getAll(key).flatMap((value) => value.split(",")).filter(Boolean); return values.length ? [...new Set(values)] : fallback; } function matches(values: string[], value: string): boolean { return !values.length || values.includes(value); } function exportMonitoringExcel(rows: MonitoringRow[]) { const headers = [ "银行", "是否无极银行", "模型名称", "模型版本", "模型ID", "月份", "模型状态", "排序性", "KS", "PSI", "KS环比降幅", "模型异常等级", "模型监控结果等级", "异常原因", ]; const dataRows = rows.map((row) => [ row.bank, row.wuji ? "是" : "否", row.name, row.version, row.modelId, row.monitorMonth, row.status, row.ranking, `${row.ks.toFixed(2)}%`, `${row.psi.toFixed(2)}%`, `${row.ksDrop.toFixed(2)}%`, abnormalLevelOf(row), gradeOf(row), abnormalReasonOf(row), ]); return exportRowsToExcel({ fileName: "模型监控明细", sheetName: "监控明细", headers, rows: dataRows }); } function SortableHead({ label, sortKey, sort, onSort }: { label: string; sortKey: SortKey; sort: SortState; onSort: (key: SortKey) => void }) { return ; } export default function MonitoringOverviewPage() { const navigate = useNavigate(); const { models } = useOperationsData(); const [searchParams, setSearchParams] = useSearchParams(); const initial = defaultFilters(); const [filters, setFilters] = useState(() => ({ bank: parseValues(searchParams, "bank"), wuji: parseValues(searchParams, "wuji"), name: parseValues(searchParams, "name"), modelId: parseValues(searchParams, "modelId"), month: parseValues(searchParams, "month", initial.month), status: parseValues(searchParams, "status"), abnormal: parseValues(searchParams, "abnormal"), grade: parseValues(searchParams, "grade", initial.grade), ranking: parseValues(searchParams, "ranking"), ksBand: parseValues(searchParams, "ksBand"), psiBand: parseValues(searchParams, "psiBand"), dropBand: parseValues(searchParams, "dropBand"), })); const [sort, setSort] = useState({ key: "month", direction: "desc" }); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); const rows = useMemo(() => monitoringRows(models), [models]); useEffect(() => { const params = new URLSearchParams(); (Object.keys(filters) as Array).forEach((key) => { filters[key].forEach((value) => params.append(key, value)); }); setSearchParams(params, { replace: true }); }, [filters, setSearchParams]); const filteredRows = useMemo(() => rows.filter((row) => { if (!matches(filters.bank, row.bank)) return false; if (!matches(filters.wuji, row.wuji ? "是" : "否")) return false; if (!matches(filters.name, row.name)) return false; if (!matches(filters.modelId, row.modelId)) return false; if (!matches(filters.month, row.monitorMonth)) return false; if (!matches(filters.status, row.status)) return false; if (!matches(filters.abnormal, abnormalLevelOf(row))) return false; if (!matches(filters.grade, gradeOf(row))) return false; if (!matches(filters.ranking, row.ranking)) return false; if (!matches(filters.ksBand, row.ks >= 40 ? ">=40%" : "<40%")) return false; const psiBand = row.psi <= 10 ? "<=10%" : row.psi <= 25 ? "10%-25%" : ">25%"; if (!matches(filters.psiBand, psiBand)) return false; if (!matches(filters.dropBand, row.ksDrop > 20 ? ">20%" : "<=20%")) return false; return true; }), [filters, rows]); const sortedRows = useMemo(() => [...filteredRows].sort((left, right) => { const values: Record = { bank: [left.bank, right.bank], modelId: [left.modelId, right.modelId], month: [left.monitorMonth, right.monitorMonth], ks: [left.ks, right.ks], psi: [left.psi, right.psi], grade: [gradeOf(left), gradeOf(right)], }; const [a, b] = values[sort.key]; const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), "zh-CN"); return sort.direction === "asc" ? result : -result; }), [filteredRows, sort]); useEffect(() => setPage(1), [filters, pageSize, sort]); const pageCount = Math.max(1, Math.ceil(sortedRows.length / pageSize)); const safePage = Math.min(page, pageCount); const pageRows = sortedRows.slice((safePage - 1) * pageSize, safePage * pageSize); const rangeStart = sortedRows.length ? (safePage - 1) * pageSize + 1 : 0; const rangeEnd = Math.min(safePage * pageSize, sortedRows.length); const latestModels = models.filter((model) => model.status !== "下线"); const gradeGroups = (["A", "B", "C"] as ModelGrade[]).map((grade) => { const groupModels = latestModels.filter((model) => gradeOf(model) === grade); return { grade, models: groupModels, banks: new Set(groupModels.map((model) => model.bank)).size, title: grade === "A" ? "运行正常" : grade === "B" ? "需要关注" : "需要处理", description: grade === "A" ? "生成监控报告" : grade === "B" ? "生成诊断报告" : "诊断报告并评估模型", }; }); const update = (key: K, value: string[]) => setFilters((current) => ({ ...current, [key]: value })); const changeSort = (key: SortKey) => setSort((current) => current.key === key ? { key, direction: current.direction === "asc" ? "desc" : "asc" } : { key, direction: "asc" }); return (
void exportMonitoringExcel(sortedRows)}>导出 Excel} />
{gradeGroups.map((group) => ( {group.title}{group.description}
银行数{group.banks}
模型数{group.models.length}
))}
监控明细共 {sortedRows.length} 条 · 当前显示 {rangeStart}–{rangeEnd}
model.bank))} onChange={(value) => update("bank", value)} /> update("wuji", value)} /> model.name))} onChange={(value) => update("name", value)} /> model.modelId))} onChange={(value) => update("modelId", value)} /> update("month", value)} /> update("status", value)} /> update("abnormal", value)} /> update("grade", value)} /> update("ranking", value)} /> =40%", "<40%"]} onChange={(value) => update("ksBand", value)} /> 25%"]} onChange={(value) => update("psiBand", value)} /> 20%"]} onChange={(value) => update("dropBand", value)} />
无极银行模型名称版本状态排序性KS降幅异常等级异常原因 {pageRows.length ? pageRows.map((row) => {row.bank}{row.wuji ? "是" : "否"}{categoryName(row.category)}{row.version}{row.modelId}{row.monitorMonth}{row.ranking}{row.ks.toFixed(2)}%{row.psi.toFixed(2)}%{row.ksDrop.toFixed(2)}% navigate(`/operations/monitoring/${row.modelId}`)} />{abnormalReasonOf(row)}) : 当前筛选条件下没有监控记录}
每页 setPageSize(Number(value))} />
{safePage} / {pageCount}
); }