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,337 @@
import { useEffect, useMemo, useState } from "react";
import { ArrowRight, CheckCircle2, Clock3, FileText, Info, RefreshCw, TriangleAlert } from "lucide-react";
import { useNavigate, useParams } 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 { Input } from "~/components/ui/input";
import { Skeleton } from "~/components/ui/skeleton";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
import { Textarea } from "~/components/ui/textarea";
import {
AbnormalBadge,
FilterSelect,
GradeBadge,
MetricLineChart,
OperationsPageHeader,
SortingComboChart,
StatusBadge,
} from "./OperationsUi";
import {
FEATURE_METRICS,
SORTING_DISTRIBUTION,
abnormalLevelOf,
abnormalReasonOf,
gradeOf,
modelTrend,
monthsBetween,
} from "./modelData";
import { REPORTS } from "./reportData";
import { useOperationsData, useOperationsModelDetail } from "./OperationsDataContext";
import { isOperationsActionVisibleForRole, useOperationsRole } from "./operationsRole";
function recentMonths(count: number): string[] {
const end = 2026 * 12 + 6;
return Array.from({ length: count }, (_, index) => {
const value = end - count + 1 + index;
return `${Math.floor(value / 12)}-${String(value % 12 + 1).padStart(2, "0")}`;
});
}
export default function MonitoringDetailPage() {
const navigate = useNavigate();
const params = useParams();
const { models } = useOperationsData();
const operationsRole = useOperationsRole();
const modelId = params.modelId ?? models[0]?.modelId ?? "";
const detail = useOperationsModelDetail(modelId, "2026-07");
const baseModel = detail.model ?? models.find((item) => item.modelId === modelId) ?? models[0];
const model = detail.monitoringResult ?? baseModel ?? models[0]!;
const [range, setRange] = useState("6");
const [fromMonth, setFromMonth] = useState("2026-02");
const [toMonth, setToMonth] = useState("2026-07");
const [compareId, setCompareId] = useState("");
const [selectedFeature, setSelectedFeature] = useState<string | null>(null);
const [reviewStage, setReviewStage] = useState<0 | 1 | 2>(0);
const [reviewDecision, setReviewDecision] = useState("暂不处理");
const [reviewNote, setReviewNote] = useState("");
const months = range === "custom" ? monthsBetween(fromMonth, toMonth) : recentMonths(Number(range));
const ksTrend = modelTrend(model, "ks", months);
const psiTrend = modelTrend(model, "psi", months);
const compareModel = models.find((item) => item.modelId === compareId && item.modelId !== model.modelId) ?? null;
const compareKsTrend = compareModel ? modelTrend(compareModel, "ks", months) : null;
const comparePsiTrend = compareModel ? modelTrend(compareModel, "psi", months) : null;
const grade = gradeOf(model);
const abnormal = abnormalLevelOf(model);
const linkedReport = REPORTS.find((report) => report.modelId === model.modelId && report.monitorMonth === "2026-07") ?? null;
const chosenFeature = FEATURE_METRICS.find((item) => item.key === selectedFeature) ?? null;
const ivTop = useMemo(() => [...FEATURE_METRICS].sort((left, right) => right.ivDrop - left.ivDrop), []);
const csiTop = useMemo(() => [...FEATURE_METRICS].sort((left, right) => right.csiRise - left.csiRise), []);
const canInitialReview = isOperationsActionVisibleForRole(operationsRole, "monitor:initial-review");
const canFinalReview = isOperationsActionVisibleForRole(operationsRole, "monitor:final-review");
const effectiveReviewStage = grade === "A" ? 2 : reviewStage;
const canReview = grade !== "A" && ((reviewStage === 0 && canInitialReview) || (reviewStage === 1 && canFinalReview));
const submitReview = () => {
if (!reviewNote.trim()) {
toast.error("请填写处理说明");
return;
}
if (reviewStage === 0) {
setReviewStage(1);
toast.success(`模型团队初审已提交:${reviewDecision}`);
} else {
setReviewStage(2);
toast.success(`业务团队终审已提交:${reviewDecision}`);
}
setReviewNote("");
};
useEffect(() => {
setReviewStage(0);
setReviewDecision("暂不处理");
setReviewNote("");
setSelectedFeature(null);
setCompareId("");
}, [modelId]);
if (detail.loading) {
return <section className="h-full overflow-auto bg-bg p-6"><div className="mx-auto max-w-screen-2xl space-y-6"><Skeleton className="h-20 w-full rounded-4xl" /><Skeleton className="h-96 w-full rounded-4xl" /><div className="grid grid-cols-2 gap-6"><Skeleton className="h-72 rounded-4xl" /><Skeleton className="h-72 rounded-4xl" /></div></div></section>;
}
if (detail.error) {
return <section className="grid h-full place-items-center bg-bg p-6"><Card className="w-full max-w-xl"><CardContent className="flex flex-col items-center py-12 text-center"><TriangleAlert className="size-10 text-danger" /><h2 className="mt-4 text-xl font-bold text-foreground"></h2><p className="mt-2 text-sm text-muted-foreground">{detail.error ?? "模型不存在"}</p><Button className="mt-5" onClick={detail.reload}><RefreshCw /></Button></CardContent></Card></section>;
}
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={`${model.bank} · ${model.name} ${model.version} · ${model.modelId}`}
onBack={() => navigate("/operations/monitoring")}
actions={(
<div className="flex items-end gap-2">
<FilterSelect
className="w-72"
label="监控模型"
value={model.modelId}
allLabel="请选择模型"
options={models.filter((item) => item.status !== "下线").map((item) => ({
label: `${item.bank} · ${item.name} ${item.version}`,
value: item.modelId,
}))}
onChange={(modelId) => {
if (modelId) navigate(`/operations/monitoring/${modelId}`);
}}
/>
<FilterSelect
className="w-40"
label="时间范围"
value={range}
allLabel="请选择"
options={[{ label: "近 3 个月", value: "3" }, { label: "近 6 个月", value: "6" }, { label: "近 12 个月", value: "12" }, { label: "自定义", value: "custom" }]}
onChange={setRange}
/>
<FilterSelect
className="w-64"
label="对比模型"
value={compareId}
allLabel="不对比"
options={models.filter((item) => item.status !== "下线" && item.modelId !== model.modelId).map((item) => ({ label: `${item.bank} · ${item.modelId}`, value: item.modelId }))}
onChange={setCompareId}
/>
{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>
</>
)}
</div>
)}
/>
<Card>
<CardHeader className="border-b border-border">
<CardTitle> </CardTitle>
<CardDescription>2026-07 · </CardDescription>
<CardAction className="flex items-center gap-2">
<StatusBadge status={model.status} />
<GradeBadge grade={grade} suffix="等级" />
</CardAction>
</CardHeader>
<CardContent className="space-y-5">
<div className="grid grid-cols-6 gap-4">
{[
["监控结果等级", grade],
["排序性", model.ranking],
["KS", `${model.ks.toFixed(2)}%`],
["PSI", `${model.psi.toFixed(2)}%`],
["KS 环比降幅", `${model.ksDrop.toFixed(2)}%`],
["异常等级", abnormal],
].map(([label, value]) => (
<div className="rounded-xl bg-muted/50 p-4" key={label}>
<span className="text-xs text-muted-foreground">{label}</span>
<strong className="mt-2 block whitespace-nowrap text-xl font-bold tabular-nums text-foreground">{value}</strong>
</div>
))}
</div>
{grade === "A" ? (
<div className="flex items-start gap-3 rounded-xl bg-success-soft p-4 text-sm text-success-strong">
<CheckCircle2 className="mt-0.5 size-5 shrink-0" />
<div><b></b><p className="mt-1"></p></div>
</div>
) : (
<div className="flex items-start gap-3 rounded-xl bg-warning-soft p-4 text-sm text-foreground">
<TriangleAlert className="mt-0.5 size-5 shrink-0 text-warning" />
<div>
<b>{abnormal}</b>
<p className="mt-1 text-muted-foreground">{abnormalReasonOf(model)}</p>
{(model.ks < 30 || model.psi > 50) && <p className="mt-2 font-medium text-danger"></p>}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="flex items-start gap-3 rounded-xl border border-border p-4">
<Clock3 className="mt-0.5 size-5 shrink-0 text-primary" />
<div className="flex-1">
<b className="text-sm text-foreground"></b>
<p className="mt-1 text-sm text-muted-foreground">{grade === "A" ? "本期无异常,无需进入处理流程。" : effectiveReviewStage === 0 ? "待模型团队初审,初审完成后流转至业务团队终审。" : effectiveReviewStage === 1 ? "模型团队已完成初审,待业务团队终审。" : "本期处理已结案。"}</p>
<div className="mt-3 flex items-center gap-2 text-xs text-muted-foreground">
<span className={`rounded-full px-2.5 py-1 ${effectiveReviewStage >= 1 ? "bg-success text-white" : "bg-primary text-white"}`}>1 </span>
<ArrowRight className="size-3.5" />
<span className={`rounded-full px-2.5 py-1 ${effectiveReviewStage >= 2 ? "bg-success text-white" : effectiveReviewStage === 1 ? "bg-primary text-white" : "bg-muted"}`}>2 </span>
</div>
{canReview && <div className="mt-4 space-y-3 border-t border-border pt-4"><FilterSelect label={reviewStage === 0 ? "模型团队处理建议" : "业务团队终审结论"} value={reviewDecision} allLabel="请选择" options={["暂不处理", "模型微调或重构"]} onChange={setReviewDecision} /><Textarea placeholder="必填:请输入本次处理依据或结论" value={reviewNote} onChange={(event) => setReviewNote(event.target.value)} /><Button size="sm" onClick={submitReview}>{reviewStage === 0 ? "初审" : "终审"}</Button></div>}
{!canReview && grade !== "A" && effectiveReviewStage < 2 && <p className="mt-3 text-xs text-muted-foreground">{effectiveReviewStage === 0 ? "当前角色需等待模型团队完成初审。" : "当前角色需等待业务团队完成终审。"}</p>}
</div>
</div>
<div className="flex items-start gap-3 rounded-xl border border-border p-4">
<FileText className="mt-0.5 size-5 shrink-0 text-primary" />
<div className="flex-1">
<b className="text-sm text-foreground"></b>
<p className="mt-1 text-sm text-muted-foreground">{grade === "A" ? "模型监控报告" : "模型诊断报告"} · 2026-07 · 2026-08-15</p>
<Button className="mt-2 px-0" variant="link" size="sm" onClick={() => navigate(linkedReport ? `/operations/reports?report=${linkedReport.reportId}` : "/operations/reports")}> <ArrowRight /></Button>
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="border-b border-border">
<CardTitle> </CardTitle>
<CardDescription>{months[0]} {months[months.length - 1]} · PSI 使{compareModel ? ` · 对比 ${compareModel.modelId}` : ""}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-2 gap-6">
<Card size="sm">
<CardHeader><CardTitle>KS </CardTitle><CardDescription>线 40%线 30%</CardDescription></CardHeader>
<CardContent><MetricLineChart months={months} values={ksTrend} name={`${model.bank} ${model.modelId} · KS`} comparison={compareModel && compareKsTrend ? { name: `${compareModel.bank} ${compareModel.modelId}`, values: compareKsTrend } : undefined} thresholds={[{ value: 40, label: "40% 分档线", tone: "danger" }, { value: 30, label: "30% 干预线", tone: "warning" }]} /></CardContent>
</Card>
<Card size="sm">
<CardHeader><CardTitle>PSI </CardTitle><CardDescription>10% 线25% 线</CardDescription></CardHeader>
<CardContent><MetricLineChart months={months} values={psiTrend} name={`${model.bank} ${model.modelId} · PSI`} comparison={compareModel && comparePsiTrend ? { name: `${compareModel.bank} ${compareModel.modelId}`, values: comparePsiTrend } : undefined} thresholds={[{ value: 10, label: "10% 关注线", tone: "warning" }, { value: 25, label: "25% 偏移线", tone: "danger" }]} /></CardContent>
</Card>
</div>
<Card size="sm">
<CardHeader><CardTitle></CardTitle><CardDescription>2026-07 · 线</CardDescription></CardHeader>
<CardContent><SortingComboChart {...SORTING_DISTRIBUTION} /></CardContent>
</Card>
<div className="grid grid-cols-2 gap-6">
<Card size="sm">
<CardHeader><CardTitle>IV · 5 </CardTitle><CardDescription></CardDescription></CardHeader>
<CardContent className="space-y-3">
{ivTop.map((feature) => (
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3" key={feature.key}>
<span><b className="block text-sm text-foreground">{feature.name}</b><small className="font-mono text-xs text-muted-foreground">{feature.key}</small></span>
<span className="text-right"><b className="block tabular-nums text-danger">-{feature.ivDrop}%</b><small className="text-xs text-muted-foreground">IV {feature.iv.toFixed(3)}</small></span>
</div>
))}
</CardContent>
</Card>
<Card size="sm">
<CardHeader><CardTitle>CSI · 5 </CardTitle><CardDescription></CardDescription></CardHeader>
<CardContent className="space-y-3">
{csiTop.map((feature) => (
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3" key={feature.key}>
<span><b className="block text-sm text-foreground">{feature.name}</b><small className="font-mono text-xs text-muted-foreground">{feature.key}</small></span>
<span className="text-right"><b className="block tabular-nums text-warning">+{feature.csiRise}pp</b><small className="text-xs text-muted-foreground">CSI {feature.csi.toFixed(3)}</small></span>
</div>
))}
</CardContent>
</Card>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="border-b border-border">
<CardTitle> </CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="px-0">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>IV</TableHead>
<TableHead>IV </TableHead>
<TableHead>CSI</TableHead>
<TableHead>CSI </TableHead>
<TableHead>KS </TableHead>
<TableHead>PSI </TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{FEATURE_METRICS.map((feature) => (
<TableRow data-state={selectedFeature === feature.key ? "selected" : undefined} key={feature.key}>
<TableCell className="font-mono text-xs">{feature.key}</TableCell>
<TableCell className="font-medium text-foreground">{feature.name}</TableCell>
<TableCell className="tabular-nums">{feature.iv.toFixed(3)}</TableCell>
<TableCell className="tabular-nums text-danger">-{feature.ivDrop}%</TableCell>
<TableCell className="tabular-nums">{feature.csi.toFixed(3)}</TableCell>
<TableCell className="tabular-nums text-warning">+{feature.csiRise}pp</TableCell>
<TableCell className="tabular-nums">{feature.ksContribution > 0 ? "+" : ""}{feature.ksContribution}pp</TableCell>
<TableCell className="tabular-nums">{feature.psiContribution > 0 ? "+" : ""}{feature.psiContribution}pp</TableCell>
<TableCell className="text-right"><Button variant="link" size="sm" onClick={() => setSelectedFeature(feature.key)}></Button></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
{chosenFeature && (
<CardContent className="border-t border-border">
<div className="mb-4 flex items-start gap-3 rounded-xl bg-brand-soft p-4 text-sm text-primary">
<Info className="mt-0.5 size-5 shrink-0" />
<div><b>{chosenFeature.name}{chosenFeature.key}</b><p className="mt-1 text-primary/80"> 2026-07 </p></div>
</div>
<div className="grid grid-cols-5 gap-4">
{[38, 27, 18, 11, 6].map((reference, index) => {
const current = Math.max(2, reference + [4, -3, 2, -1, -2][index]);
return (
<div className="rounded-xl border border-border p-4" key={reference}>
<span className="text-xs text-muted-foreground"> {index + 1}</span>
<div className="mt-3 space-y-2">
<div><span className="flex justify-between text-xs"><i></i><b>{reference}%</b></span><span className="mt-1 block h-2 overflow-hidden rounded-full bg-muted"><i className="block h-full bg-ink-subtle" style={{ width: `${reference * 2}%` }} /></span></div>
<div><span className="flex justify-between text-xs"><i></i><b>{current}%</b></span><span className="mt-1 block h-2 overflow-hidden rounded-full bg-muted"><i className="block h-full bg-primary" style={{ width: `${current * 2}%` }} /></span></div>
</div>
</div>
);
})}
</div>
</CardContent>
)}
</Card>
</div>
</section>
);
}