Files
model-platform/frontend/app/features/operations/MonitoringOverviewPage.tsx
T

232 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}