- 新增 model_deploy 与 model_operations 双库查询,支持模型列表、模型详情、单月监控结果和运维工作台真实接口。 - 关闭运维模块生产 Mock 数据,补充缺表/空数据降级、工作台空状态和首条纵向链路测试。 - 按业务团队、模型团队、管理员权限矩阵接入菜单、页面、操作按钮和后端接口权限校验,支持 business_team 角色。 - 更新工作台布局、深色欢迎卡片、全宽页面适配、顶部回退,以及权限分组展示。 - 新增架构实现基线、周目标完成情况和角色权限矩阵初始化 SQL 文档。
323 lines
19 KiB
TypeScript
323 lines
19 KiB
TypeScript
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 { useOperationsData, useOperationsModelDetail } from "./OperationsDataContext";
|
||
import { useCanOperationsAction } 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 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 monitorMonth = detail.monitoringResult?.monitorMonth ?? "";
|
||
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 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 = useCanOperationsAction("monitor:initial-review");
|
||
const canFinalReview = useCanOperationsAction("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="w-full 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="flex w-full 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>{monitorMonth ? `${monitorMonth} 监控周期 · 结果优先展示` : "暂无监控结果"}</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">{monitorMonth ? `${grade === "A" ? "模型监控报告" : "模型诊断报告"} · ${monitorMonth}` : "暂无关联报告"}</p>
|
||
<Button className="mt-2 px-0" variant="link" size="sm" onClick={() => navigate("/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>{monitorMonth || "当期"} · 蓝色柱为客户数,橙色折线为坏客户占比</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">对比基准期与当期各分箱占比,正式数据由后端接口读取。</p></div>
|
||
</div>
|
||
<div className="rounded-xl border border-dashed border-border p-8 text-center text-sm text-muted-foreground">暂无特征分布数据</div>
|
||
</CardContent>
|
||
)}
|
||
</Card>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|