feat: add A-card operations frontend and backend foundation

This commit is contained in:
郑龙捷
2026-09-02 09:54:03 +08:00
parent 9badd3597f
commit ad71259ba5
106 changed files with 12461 additions and 1796 deletions
@@ -0,0 +1,231 @@
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>
);
}