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

338 lines
20 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 { 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>
);
}