Files
model-platform/frontend/app/features/operations/MonitoringDetailPage.tsx
T
郑龙捷 74dd97428f feat: 接入运维真实数据与三角色权限链路
- 新增 model_deploy 与 model_operations 双库查询,支持模型列表、模型详情、单月监控结果和运维工作台真实接口。

- 关闭运维模块生产 Mock 数据,补充缺表/空数据降级、工作台空状态和首条纵向链路测试。

- 按业务团队、模型团队、管理员权限矩阵接入菜单、页面、操作按钮和后端接口权限校验,支持 business_team 角色。

- 更新工作台布局、深色欢迎卡片、全宽页面适配、顶部回退,以及权限分组展示。

- 新增架构实现基线、周目标完成情况和角色权限矩阵初始化 SQL 文档。
2026-09-02 15:02:47 +08:00

323 lines
19 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 { 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>
);
}