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,167 @@
import { useMemo, useRef, useState } from "react";
import { ArrowRight, Download, TrendingDown, TrendingUp } from "lucide-react";
import { useNavigate, useSearchParams } from "react-router";
import { toast } from "sonner";
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 { FilterSelect, GradeBadge, MetricLineChart, OperationsPageHeader } from "./OperationsUi";
import { useOperationsData } from "./OperationsDataContext";
import {
MODEL_CATEGORIES,
MONITOR_MONTHS,
abnormalLevelOf,
average,
categoryName,
categoryTrend,
gradeOf,
latestIterationDate,
modelIterationCycleMonths,
monthsBetween,
type ModelCategoryId,
type ModelGrade,
type ModelRecord,
} from "./modelData";
const CATEGORY_AVERAGE_CYCLE: Record<ModelCategoryId, number> = { std: 9.3, bai: 12, big: 8, afd: 14 };
type CategoryCardItem = {
id: string;
name: string;
models: ModelRecord[];
banks: number;
versions: number;
latestIteration: string;
averageCycle: number;
averageKs: number;
averagePsi: number;
gradeCounts: Record<ModelGrade, number>;
demo?: boolean;
};
function isCategoryId(value: string | null): value is ModelCategoryId {
return MODEL_CATEGORIES.some((category) => category.id === value);
}
function DeviationTable({ 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 (
<Card size="sm">
<CardHeader className="border-b border-border">
<CardTitle className="flex items-center gap-2">
{isPsi ? <TrendingUp className="size-4 text-warning" /> : <TrendingDown className="size-4 text-warning" />}
{metric} {isPsi ? "高于" : "低于"}
</CardTitle>
<CardDescription> {averageValue.toFixed(2)}% · {rows.length} </CardDescription>
<CardAction><Button variant="outline" size="xs" onClick={() => void exportRowsToExcel({ fileName: `${metric}_${isPsi ? "高于" : "低于"}平均值`, sheetName: `${metric}偏离平均值`, headers: ["银行", "模型名称", "模型版本", "模型ID", `当月${metric}`, "监控结果等级", "月份", "最近处理", "上次处理建议"], rows: rows.map((model) => [model.bank, model.name, model.version, model.modelId, isPsi ? model.psi : model.ks, gradeOf(model), "2026-07", model.processedAt ?? "未处理", model.previousAdvice]) })}><Download /> Excel</Button></CardAction>
</CardHeader>
<CardContent className="px-0">
<Table>
<TableHeader><TableRow><TableHead> / </TableHead><TableHead> {metric}</TableHead><TableHead> / </TableHead><TableHead></TableHead><TableHead></TableHead></TableRow></TableHeader>
<TableBody>{rows.length ? rows.map((model) => {
const value = isPsi ? model.psi : model.ks;
return <TableRow key={`${metric}-${model.modelId}`}><TableCell><strong className="block text-foreground">{model.bank}</strong><small className="text-muted-foreground">{model.name} · {model.version}</small></TableCell><TableCell><strong className="block tabular-nums text-foreground">{value.toFixed(2)}%</strong><small className="text-warning">{isPsi ? "↑" : "↓"} {Math.abs(value - averageValue).toFixed(2)}pp</small></TableCell><TableCell><span className="flex items-center gap-2"><GradeBadge grade={gradeOf(model)} onClick={() => onOpenDetail(model.modelId)} /><span className="font-mono text-xs text-muted-foreground">2026-07</span></span></TableCell><TableCell>{model.processedAt ?? "未处理"}</TableCell><TableCell className="min-w-48 whitespace-normal text-muted-foreground">{model.previousAdvice}</TableCell></TableRow>;
}) : <TableRow><TableCell className="h-28 text-center text-muted-foreground" colSpan={5}></TableCell></TableRow>}</TableBody>
</Table>
</CardContent>
</Card>
);
}
export default function ModelOverviewPage() {
const navigate = useNavigate();
const { models } = useOperationsData();
const [searchParams, setSearchParams] = useSearchParams();
const trendRef = useRef<HTMLDivElement>(null);
const initialCategory = isCategoryId(searchParams.get("category")) ? searchParams.get("category") as ModelCategoryId : "std";
const [category, setCategory] = useState<ModelCategoryId>(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 selectedName = categoryName(category);
const selectedModels = models.filter((model) => model.category === category && model.status !== "下线");
const averageKs = average(selectedModels.map((model) => model.ks));
const averagePsi = average(selectedModels.map((model) => model.psi));
const ksSeries = categoryTrend(category, "ks", months, models);
const psiSeries = categoryTrend(category, "psi", months, models);
const categoryCards = useMemo<CategoryCardItem[]>(() => {
const realCards = MODEL_CATEGORIES.map((item) => {
const categoryModels = models.filter((model) => model.category === item.id && model.status !== "下线");
const gradeCounts = categoryModels.reduce<Record<ModelGrade, number>>((result, model) => ({ ...result, [gradeOf(model)]: result[gradeOf(model)] + 1 }), { A: 0, B: 0, C: 0 });
return { ...item, models: categoryModels, banks: new Set(categoryModels.map((model) => model.bank)).size, versions: new Set(categoryModels.map((model) => model.version)).size, latestIteration: latestIterationDate(categoryModels), averageCycle: CATEGORY_AVERAGE_CYCLE[item.id], averageKs: average(categoryModels.map((model) => model.ks)), averagePsi: average(categoryModels.map((model) => model.psi)), gradeCounts };
});
return [...realCards, { id: "consumer-demo", name: "消费贷评分(示意)", models: [], banks: 5, versions: 4, latestIteration: "2026-07-18", averageCycle: 10.6, averageKs: 39.6, averagePsi: 14.2, gradeCounts: { A: 2, B: 2, C: 1 }, demo: true }];
}, [models]);
const selectCategory = (value: ModelCategoryId, scroll = false) => {
setCategory(value);
setSearchParams({ category: value });
if (scroll) requestAnimationFrame(() => trendRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }));
};
const openGrade = (item: CategoryCardItem, grade: ModelGrade) => {
if (item.demo) return toast.info("该卡片仅用于展示第 5 个及以上模型大类的纵向滚动效果");
setDrilldown({ category: item.id as ModelCategoryId, grade, models: item.models.filter((model) => gradeOf(model) === grade) });
};
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="按模型大类汇总全部银行;点击大类卡片可定位到对应指标趋势。" actions={<Button variant="outline" onClick={() => navigate("/operations/monitoring")}> <ArrowRight /></Button>} />
<CategoryCardRail>
{categoryCards.map((item) => {
const total = item.gradeCounts.A + item.gradeCounts.B + item.gradeCounts.C || 1;
const selected = !item.demo && item.id === category;
const activate = () => item.demo ? toast.info("扩展示意卡:正式接入第 5 个大类后沿用相同卡片结构") : selectCategory(item.id as ModelCategoryId, true);
return (
<Card className={`cursor-pointer transition-all hover:-translate-y-0.5 hover:ring-1 hover:ring-primary/40 ${selected ? "ring-2 ring-primary" : ""}`} key={item.id} role="button" size="sm" tabIndex={0} onClick={activate} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") activate(); }}>
<CardHeader><CardTitle className="flex items-center gap-2">{item.name}<span className="rounded-lg bg-muted px-2 py-1 text-2xs font-medium text-muted-foreground">{item.demo ? "扩展示意" : "全部银行"}</span></CardTitle></CardHeader>
<CardContent className="space-y-3">
<dl className="grid grid-cols-3 gap-3">{[["银行数", item.banks], ["模型数", item.demo ? 5 : item.models.length], ["版本数", item.versions]].map(([label, value]) => <div key={label}><dt className="whitespace-nowrap text-2xs text-ink-caption">{label}</dt><dd className="mt-1 text-lg font-bold tabular-nums">{value}</dd></div>)}</dl>
<dl className="grid grid-cols-2 gap-3"><div><dt className="text-2xs text-ink-caption"></dt><dd className="mt-1 whitespace-nowrap text-sm font-bold tabular-nums">{item.latestIteration}</dd></div><div><dt className="whitespace-nowrap text-2xs text-ink-caption"></dt><dd className="mt-1 whitespace-nowrap text-lg font-bold tabular-nums">{item.averageCycle.toFixed(1)}</dd></div></dl>
<dl className="grid grid-cols-2 gap-3"><div><dt className="text-2xs text-ink-caption"> KS</dt><dd className="mt-1 text-xl font-bold tabular-nums">{item.averageKs.toFixed(1)}%</dd></div><div><dt className="text-2xs text-ink-caption"> PSI</dt><dd className="mt-1 text-xl font-bold tabular-nums">{item.averagePsi.toFixed(1)}%</dd></div></dl>
<div className="flex h-2 overflow-hidden rounded-full bg-muted" aria-label="模型监控结果等级分布"><i className="bg-success" style={{ width: `${item.gradeCounts.A / total * 100}%` }} /><i className="bg-warning" style={{ width: `${item.gradeCounts.B / total * 100}%` }} /><i className="bg-danger" style={{ width: `${item.gradeCounts.C / total * 100}%` }} /></div>
<div className="flex flex-wrap gap-2" onClick={(event) => event.stopPropagation()}>{(["A", "B", "C"] as ModelGrade[]).map((grade) => <GradeBadge grade={grade} key={grade} suffix={`${item.gradeCounts[grade]} 个模型`} onClick={() => openGrade(item, grade)} />)}</div>
</CardContent>
</Card>
);
})}
</CategoryCardRail>
<Card ref={trendRef} className="scroll-mt-20">
<CardHeader className="border-b border-border"><CardTitle></CardTitle><CardDescription></CardDescription><CardAction className="flex items-end gap-2"><FilterSelect className="w-40" label="模型大类" allLabel="请选择" value={category} options={MODEL_CATEGORIES.map((item) => ({ label: item.name, value: item.id }))} onChange={(value) => { if (isCategoryId(value)) selectCategory(value); }} /><FilterSelect className="w-40" label="时间范围" allLabel="请选择" value={range} options={[{ label: "近 6 个月", value: "6" }, { label: "自定义", value: "custom" }]} onChange={setRange} />{range === "custom" && <><label className="flex flex-col gap-1.5"><span className="text-xs font-medium text-ink-caption"></span><Input type="month" value={fromMonth} onChange={(event) => setFromMonth(event.target.value)} /></label><label className="flex flex-col gap-1.5"><span className="text-xs font-medium text-ink-caption"></span><Input type="month" value={toMonth} onChange={(event) => setToMonth(event.target.value)} /></label></>}</CardAction></CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-2 items-start gap-6"><DeviationTable metric="KS" models={selectedModels} averageValue={averageKs} onOpenDetail={(modelId) => navigate(`/operations/monitoring/${modelId}`)} /><DeviationTable metric="PSI" models={selectedModels} averageValue={averagePsi} onOpenDetail={(modelId) => navigate(`/operations/monitoring/${modelId}`)} /></div>
<div className="grid grid-cols-2 gap-6"><Card size="sm"><CardHeader><CardTitle> KS </CardTitle><CardDescription>{selectedName} · {months[0]} {months.at(-1)} · {averageKs.toFixed(2)}%</CardDescription></CardHeader><CardContent><MetricLineChart months={months} values={ksSeries} name={`${selectedName} 平均 KS`} thresholds={[{ value: 40, label: "40% 分档线", tone: "danger" }, { value: 30, label: "30% 干预线", tone: "warning" }]} /></CardContent></Card><Card size="sm"><CardHeader><CardTitle> PSI </CardTitle><CardDescription>{selectedName} · {months[0]} {months.at(-1)} · {averagePsi.toFixed(2)}%</CardDescription></CardHeader><CardContent><MetricLineChart months={months} values={psiSeries} name={`${selectedName} 平均 PSI`} thresholds={[{ value: 10, label: "10% 关注线", tone: "warning" }, { value: 25, label: "25% 偏移线", tone: "danger" }]} /></CardContent></Card></div>
</CardContent>
</Card>
</div>
<Dialog open={Boolean(drilldown)} onOpenChange={(open) => { if (!open) setDrilldown(null); }}>
<DialogContent className="sm:max-w-6xl">
<DialogHeader><DialogTitle>{drilldown ? `${categoryName(drilldown.category)} · ${drilldown.grade} 等级模型` : "等级模型"}</DialogTitle><DialogDescription> {new Set(drilldown?.models.map((model) => model.bank)).size} {drilldown?.models.length ?? 0} </DialogDescription></DialogHeader>
<Table><TableHeader><TableRow><TableHead></TableHead><TableHead>ID</TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead>KS</TableHead><TableHead>PSI</TableHead><TableHead>KS环比降幅</TableHead><TableHead></TableHead><TableHead className="text-right"></TableHead></TableRow></TableHeader><TableBody>{drilldown?.models.length ? drilldown.models.map((model) => { const cycle = modelIterationCycleMonths(model); return <TableRow key={model.modelId}><TableCell>{model.bank}</TableCell><TableCell className="font-mono text-xs">{model.modelId}</TableCell><TableCell>{model.iteratedAt}</TableCell><TableCell>{cycle === null ? "—" : `${cycle} 个月`}</TableCell><TableCell>{model.ranking}</TableCell><TableCell>{model.ks.toFixed(2)}%</TableCell><TableCell>{model.psi.toFixed(2)}%</TableCell><TableCell>{model.ksDrop.toFixed(2)}%</TableCell><TableCell>{abnormalLevelOf(model)}</TableCell><TableCell className="text-right"><Button variant="link" size="sm" onClick={() => { setDrilldown(null); navigate(`/operations/monitoring/${model.modelId}`); }}></Button></TableCell></TableRow>; }) : <TableRow><TableCell className="h-28 text-center text-muted-foreground" colSpan={10}></TableCell></TableRow>}</TableBody></Table>
<DialogFooter><Button variant="outline" onClick={() => setDrilldown(null)}></Button>{drilldown?.models.length ? <Button variant="outline" onClick={() => void exportRowsToExcel({ fileName: `${categoryName(drilldown.category)}_${drilldown.grade}等级模型`, sheetName: "等级模型", headers: ["银行", "模型ID", "最近迭代日期", "平均迭代周期", "排序性", "KS", "PSI", "KS环比降幅", "异常等级"], rows: drilldown.models.map((model) => [model.bank, model.modelId, model.iteratedAt, modelIterationCycleMonths(model) ?? "—", model.ranking, model.ks, model.psi, model.ksDrop, abnormalLevelOf(model)]) })}><Download /> Excel</Button> : null}<Button onClick={() => { if (drilldown) selectCategory(drilldown.category, true); setDrilldown(null); }}></Button></DialogFooter>
</DialogContent>
</Dialog>
</section>
);
}