feat: add A-card operations frontend and backend foundation
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user