232 lines
15 KiB
TypeScript
232 lines
15 KiB
TypeScript
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 <TableHead><button className="inline-flex items-center gap-1.5 font-medium hover:text-primary" type="button" onClick={() => onSort(sortKey)}>{label}<ArrowUpDown className={`size-3.5 ${sort.key === sortKey ? "text-primary" : "text-muted-foreground"}`} /></button></TableHead>;
|
||
}
|
||
|
||
export default function MonitoringOverviewPage() {
|
||
const navigate = useNavigate();
|
||
const { models } = useOperationsData();
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
const initial = defaultFilters();
|
||
const [filters, setFilters] = useState<Filters>(() => ({
|
||
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<SortState>({ 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<keyof Filters>).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<SortKey, [string | number, string | number]> = {
|
||
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 = <K extends ArrayFilterKey>(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 (
|
||
<section className="h-full overflow-auto bg-bg p-6">
|
||
<div className="mx-auto flex max-w-screen-2xl flex-col gap-6 pb-8">
|
||
<OperationsPageHeader title="模型监控概览" description="查看监控结果等级分布与按月展开的监控明细,默认展示最新月份的 B / C 等级模型。" actions={<Button variant="outline" onClick={() => void exportMonitoringExcel(sortedRows)}><Download />导出 Excel</Button>} />
|
||
|
||
<div className="grid grid-cols-3 gap-4">
|
||
{gradeGroups.map((group) => (
|
||
<Card key={group.grade} size="sm">
|
||
<CardHeader><CardTitle className="flex items-center gap-2"><GradeBadge grade={group.grade} />{group.title}</CardTitle><CardDescription>{group.description}</CardDescription><CardAction><Button variant="ghost" size="sm" onClick={() => setFilters((current) => ({ ...current, grade: [group.grade], month: ["2026-07"] }))}>筛选该等级</Button></CardAction></CardHeader>
|
||
<CardContent className="grid grid-cols-2 gap-4"><div className="rounded-xl bg-muted/50 p-3"><span className="text-xs text-muted-foreground">银行数</span><strong className="mt-1 block text-xl tabular-nums">{group.banks}</strong></div><div className="rounded-xl bg-muted/50 p-3"><span className="text-xs text-muted-foreground">模型数</span><strong className="mt-1 block text-xl tabular-nums">{group.models.length}</strong></div></CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
|
||
<Card className="overflow-visible">
|
||
<CardHeader className="border-b border-border"><CardTitle>监控明细</CardTitle><CardDescription>共 {sortedRows.length} 条 · 当前显示 {rangeStart}–{rangeEnd}</CardDescription><CardAction className="flex gap-2"><Button variant="outline" size="sm" onClick={() => setFilters(defaultFilters())}><RotateCcw />重置筛选</Button><Button size="sm" onClick={() => void exportMonitoringExcel(sortedRows)}><Download />导出 Excel</Button></CardAction></CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="grid grid-cols-6 gap-3">
|
||
<MultiSelectFilter label="银行" values={filters.bank} options={unique(models.map((model) => model.bank))} onChange={(value) => update("bank", value)} />
|
||
<MultiSelectFilter label="是否无极银行" values={filters.wuji} options={["是", "否"]} onChange={(value) => update("wuji", value)} />
|
||
<MultiSelectFilter label="模型名称" values={filters.name} options={unique(models.map((model) => model.name))} onChange={(value) => update("name", value)} />
|
||
<MultiSelectFilter label="模型 ID" values={filters.modelId} options={unique(models.map((model) => model.modelId))} onChange={(value) => update("modelId", value)} />
|
||
<MultiSelectFilter label="月份" values={filters.month} options={[...MONITOR_MONTHS].reverse()} onChange={(value) => update("month", value)} />
|
||
<MultiSelectFilter label="模型状态" values={filters.status} options={["正常", "陪跑", "陪跑结束", "下线"]} onChange={(value) => update("status", value)} />
|
||
<MultiSelectFilter label="模型异常等级" values={filters.abnormal} options={["三级", "二级", "一级", "正常"]} onChange={(value) => update("abnormal", value)} />
|
||
<MultiSelectFilter label="监控结果等级" values={filters.grade} options={["A", "B", "C"]} onChange={(value) => update("grade", value)} />
|
||
<MultiSelectFilter label="排序性" values={filters.ranking} options={["相符", "不符"]} onChange={(value) => update("ranking", value)} />
|
||
<MultiSelectFilter label="KS" values={filters.ksBand} options={[">=40%", "<40%"]} onChange={(value) => update("ksBand", value)} />
|
||
<MultiSelectFilter label="PSI" values={filters.psiBand} options={["<=10%", "10%-25%", ">25%"]} onChange={(value) => update("psiBand", value)} />
|
||
<MultiSelectFilter label="KS 环比降幅" values={filters.dropBand} options={["<=20%", ">20%"]} onChange={(value) => update("dropBand", value)} />
|
||
</div>
|
||
</CardContent>
|
||
|
||
<CardContent className="overflow-visible px-0 pt-0">
|
||
<Table className="table-fixed text-[11px]" containerClassName="overflow-visible">
|
||
<colgroup><col className="w-[7%]" /><col className="w-[5%]" /><col className="w-[7%]" /><col className="w-[5%]" /><col className="w-[9%]" /><col className="w-[6%]" /><col className="w-[6%]" /><col className="w-[5%]" /><col className="w-[5%]" /><col className="w-[5%]" /><col className="w-[6%]" /><col className="w-[6%]" /><col className="w-[6%]" /><col className="w-[18%]" /></colgroup>
|
||
<TableHeader className="[&_th]:h-auto [&_th]:whitespace-normal [&_th]:px-1 [&_th]:py-2 [&_th]:text-[10px]"><TableRow><SortableHead label="银行" sortKey="bank" sort={sort} onSort={changeSort} /><TableHead>无极银行</TableHead><TableHead>模型名称</TableHead><TableHead>版本</TableHead><SortableHead label="模型ID" sortKey="modelId" sort={sort} onSort={changeSort} /><SortableHead label="月份" sortKey="month" sort={sort} onSort={changeSort} /><TableHead>状态</TableHead><TableHead>排序性</TableHead><SortableHead label="KS" sortKey="ks" sort={sort} onSort={changeSort} /><SortableHead label="PSI" sortKey="psi" sort={sort} onSort={changeSort} /><TableHead>KS降幅</TableHead><TableHead>异常等级</TableHead><SortableHead label="结果等级" sortKey="grade" sort={sort} onSort={changeSort} /><TableHead>异常原因</TableHead></TableRow></TableHeader>
|
||
<TableBody className="[&_td]:whitespace-normal [&_td]:px-1 [&_td]:py-2 [&_td]:leading-4">
|
||
{pageRows.length ? pageRows.map((row) => <TableRow key={`${row.monitorMonth}-${row.modelId}`}><TableCell className="break-words font-medium text-foreground">{row.bank}</TableCell><TableCell>{row.wuji ? "是" : "否"}</TableCell><TableCell className="break-words">{categoryName(row.category)}</TableCell><TableCell>{row.version}</TableCell><TableCell className="break-all font-mono text-[10px]">{row.modelId}</TableCell><TableCell className="font-mono text-[10px]">{row.monitorMonth}</TableCell><TableCell><StatusBadge status={row.status} /></TableCell><TableCell className={row.ranking === "不符" ? "font-medium text-danger" : "text-success-strong"}>{row.ranking}</TableCell><TableCell className="tabular-nums">{row.ks.toFixed(2)}%</TableCell><TableCell className="tabular-nums">{row.psi.toFixed(2)}%</TableCell><TableCell className="tabular-nums">{row.ksDrop.toFixed(2)}%</TableCell><TableCell><AbnormalBadge level={abnormalLevelOf(row)} /></TableCell><TableCell><GradeBadge grade={gradeOf(row)} onClick={() => navigate(`/operations/monitoring/${row.modelId}`)} /></TableCell><TableCell className="whitespace-normal break-words text-muted-foreground">{abnormalReasonOf(row)}</TableCell></TableRow>) : <TableRow><TableCell colSpan={14} className="h-32 text-center text-muted-foreground">当前筛选条件下没有监控记录</TableCell></TableRow>}
|
||
</TableBody>
|
||
</Table>
|
||
</CardContent>
|
||
|
||
<CardContent className="flex items-center justify-between border-t border-border py-3">
|
||
<div className="flex items-center gap-2 text-xs text-muted-foreground"><span>每页</span><FilterSelect className="w-24" label="" value={String(pageSize)} allLabel="请选择" options={["10", "20", "50"]} onChange={(value) => setPageSize(Number(value))} /><span>条</span></div>
|
||
<div className="flex items-center gap-2"><Button variant="outline" size="icon-sm" aria-label="上一页" disabled={safePage <= 1} onClick={() => setPage((value) => Math.max(1, value - 1))}><ChevronLeft /></Button><span className="min-w-20 text-center text-xs text-muted-foreground">{safePage} / {pageCount}</span><Button variant="outline" size="icon-sm" aria-label="下一页" disabled={safePage >= pageCount} onClick={() => setPage((value) => Math.min(pageCount, value + 1))}><ChevronRight /></Button></div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|