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

177 lines
14 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 { useMemo, useState } from "react";
import { Download, FileCheck2, Save, Send, TriangleAlert } from "lucide-react";
import { useNavigate, useSearchParams } 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
import { FilterSelect, MetricLineChart, OperationsPageHeader, SortingComboChart } from "./OperationsUi";
import { FEATURE_METRICS, MONITOR_MONTHS, SORTING_DISTRIBUTION, modelTrend } from "./modelData";
import { useOperationsData } from "./OperationsDataContext";
import { isOperationsActionVisibleForRole, useOperationsRole } from "./operationsRole";
import { latestReports, type ReportStatus, type ReportType } from "./reportData";
import { useReportStore } from "./reportStore";
type Filters = { bank: string; name: string; version: string; modelId: string };
function unique(values: string[]): string[] {
return [...new Set(values)].sort((left, right) => left.localeCompare(right, "zh-CN"));
}
function statusClass(status: ReportStatus): string {
if (status === "已发送业务团队") return "bg-success-soft text-success-strong";
if (status === "编辑中") return "bg-brand-soft text-primary";
return "bg-warning-soft text-warning";
}
function ReportTypeBadge({ type }: { type: ReportType }) {
return <span className={type === "监控报告" ? "inline-flex rounded-full bg-success-soft px-2.5 py-1 text-xs font-medium text-success-strong" : "inline-flex rounded-full bg-warning-soft px-2.5 py-1 text-xs font-medium text-warning"}>{type}</span>;
}
function ScoreDistributionTable({ modelId }: { modelId: string }) {
const offset = [...modelId].reduce((sum, character) => sum + character.charCodeAt(0), 0) % 5;
const rows = [
{ bin: "(0,580]", count: 310 + offset * 7 },
{ bin: "[580,620)", count: 742 + offset * 11 },
{ bin: "[620,660)", count: 2645 + offset * 19 },
{ bin: "[660,700)", count: 4812 + offset * 23 },
{ bin: "[700,+)", count: 3657 + offset * 17 },
];
const total = rows.reduce((sum, row) => sum + row.count, 0);
let cumulative = 0;
return (
<Table>
<TableHeader><TableRow><TableHead>分数区间</TableHead><TableHead>总账户数</TableHead><TableHead>账户占比</TableHead><TableHead>账户累计占比</TableHead></TableRow></TableHeader>
<TableBody>
{rows.map((row) => {
const share = row.count / total;
cumulative += share;
return <TableRow key={row.bin}><TableCell className="font-mono text-xs">{row.bin}</TableCell><TableCell className="tabular-nums">{row.count.toLocaleString("zh-CN")}</TableCell><TableCell className="tabular-nums">{(share * 100).toFixed(2)}%</TableCell><TableCell className="tabular-nums">{(cumulative * 100).toFixed(2)}%</TableCell></TableRow>;
})}
<TableRow><TableCell className="font-semibold">合计</TableCell><TableCell className="font-semibold tabular-nums">{total.toLocaleString("zh-CN")}</TableCell><TableCell className="font-semibold">100.00%</TableCell><TableCell></TableCell></TableRow>
</TableBody>
</Table>
);
}
export default function ReportPage() {
const navigate = useNavigate();
const { models } = useOperationsData();
const operationsRole = useOperationsRole();
const [searchParams] = useSearchParams();
const requestedId = searchParams.get("report");
const allReports = useReportStore((state) => state.reports);
const updateReport = useReportStore((state) => state.updateReport);
const reports = requestedId && allReports.some((report) => report.reportId === requestedId) ? allReports : latestReports(allReports);
const [filters, setFilters] = useState<Filters>({ bank: "", name: "", version: "", modelId: "" });
const [selectedId, setSelectedId] = useState(requestedId ?? reports[0]?.reportId ?? "");
const pool = reports.filter((report) => (
(!filters.bank || report.bank === filters.bank)
&& (!filters.name || report.modelName === filters.name)
&& (!filters.version || report.version === filters.version)
&& (!filters.modelId || report.modelId === filters.modelId)
));
const report = pool.find((item) => item.reportId === selectedId) ?? pool[0] ?? null;
const model = report ? models.find((item) => item.modelId === report.modelId) ?? null : null;
const isHistorical = Boolean(report && report.monitorMonth !== "2026-07");
const ksTrend = model ? modelTrend(model, "ks") : [];
const psiTrend = model ? modelTrend(model, "psi") : [];
const canEdit = !isHistorical && isOperationsActionVisibleForRole(operationsRole, "report:edit");
const canSend = !isHistorical && isOperationsActionVisibleForRole(operationsRole, "report:send");
const updateFilter = <K extends keyof Filters>(key: K, value: Filters[K]) => {
setFilters((current) => ({ ...current, [key]: value }));
};
const reportOptions = useMemo(() => pool.map((item) => ({
label: `${item.bank} · ${item.modelName} ${item.version} · ${item.monitorMonth} · ${item.type}`,
value: item.reportId,
})), [pool]);
const printReport = () => {
document.body.classList.add("printing-operations-report");
window.print();
window.setTimeout(() => document.body.classList.remove("printing-operations-report"), 300);
};
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={report ? `${report.type} · ${report.bank} ${report.modelName}` : "监控诊断报告"}
description={report ? `${report.monitorMonth} 周期 · 自动生成于 ${report.generatedAt} · 报告输出日期 ${report.outputDate}` : "当前筛选条件下没有报告"}
actions={report && <><Button variant="outline" onClick={printReport}><Download />导出 PDF</Button>{canEdit && <Button variant="outline" onClick={() => { updateReport(report.reportId, { status: "编辑中", synced: true }); toast.success("草稿已保存,并同步至历史报告汇总(Mock)"); }}><Save />保存草稿</Button>}{canSend && report.status !== "已发送业务团队" && <Button onClick={() => { updateReport(report.reportId, { status: "已发送业务团队", unreadDays: 0, synced: true }); toast.success("已模拟发送至业务团队"); }}><Send />发送业务团队</Button>}</>}
/>
<Card>
<CardHeader className="border-b border-border"><CardTitle>报告筛选</CardTitle><CardDescription>{isHistorical ? "历史报告只读查看" : "本页默认仅保留最新月份;历史报告请进入历史报告汇总"}</CardDescription><CardAction><Button variant="link" size="sm" onClick={() => navigate("/operations/report-summary")}>打开历史报告汇总</Button></CardAction></CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-4 gap-3">
<FilterSelect label="银行" value={filters.bank} options={unique(reports.map((item) => item.bank))} onChange={(value) => updateFilter("bank", value)} />
<FilterSelect label="模型名称" value={filters.name} options={unique(reports.map((item) => item.modelName))} onChange={(value) => updateFilter("name", value)} />
<FilterSelect label="模型版本" value={filters.version} options={unique(reports.map((item) => item.version))} onChange={(value) => updateFilter("version", value)} />
<FilterSelect label="模型 ID" value={filters.modelId} options={unique(reports.map((item) => item.modelId))} onChange={(value) => updateFilter("modelId", value)} />
</div>
<FilterSelect label="报告" value={report?.reportId ?? ""} allLabel={pool.length ? "请选择报告" : "无匹配报告"} options={reportOptions} onChange={setSelectedId} />
</CardContent>
</Card>
{report && model ? (
<>
{report.unreadDays >= 5 && report.status !== "已发送业务团队" && <div className="flex items-start gap-3 rounded-xl bg-danger-soft p-4 text-sm text-danger"><TriangleAlert className="mt-0.5 size-5 shrink-0" /><div><b>阅读催办</b><p className="mt-1">该报告已超过 {report.unreadDays} 个工作日未阅读,系统已发送催办短信。</p></div></div>}
<Card className="operations-report-document">
<CardHeader className="border-b border-border"><CardTitle className="flex items-center gap-2"><FileCheck2 className="size-5 text-primary" />{report.bank} · {report.modelName} · {report.version}</CardTitle><CardDescription>信用卡申请评分模型{report.type}</CardDescription><CardAction className="flex items-center gap-2"><ReportTypeBadge type={report.type} /><span className={`rounded-full px-2.5 py-1 text-xs font-medium ${statusClass(report.status)}`}>{report.status}</span>{report.synced && <span className="rounded-full bg-brand-soft px-2.5 py-1 text-xs font-medium text-primary">已同步</span>}</CardAction></CardHeader>
<CardContent className="space-y-8 py-8">
<section>
<h3 className="text-xl font-bold text-foreground">一、申请评分模型{report.type === "诊断报告" ? "诊断" : "监控"}说明</h3>
<p className="mt-3 max-w-5xl text-sm leading-7 text-muted-foreground" contentEditable={canEdit} suppressContentEditableWarning>
申请评分模型使用行内存量数据验证区分能力与稳定性。风险区分度采用具有完整表现期的核准账户样本,评分分布和稳定性采用本期申请有评分账户进行验证。
</p>
</section>
<section className="space-y-4">
<h3 className="text-xl font-bold text-foreground">二、{report.modelName}申请评分模型效果验证</h3>
<div className="grid grid-cols-4 gap-4">
{[
["验证样本", "12,186 户"], ["坏样本", "842 户"], ["KS", `${model.ks.toFixed(2)}%`], ["PSI", `${model.psi.toFixed(2)}%`],
].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 text-xl tabular-nums text-foreground">{value}</strong></div>)}
</div>
</section>
<section className="space-y-3">
<h4 className="text-base font-semibold text-foreground">(一)本期申请评分分布</h4>
<ScoreDistributionTable modelId={model.modelId} />
</section>
<section className="space-y-3">
<h4 className="text-base font-semibold text-foreground">(二)模型评分排序能力</h4>
<SortingComboChart {...SORTING_DISTRIBUTION} />
<p className="text-sm leading-7 text-muted-foreground" contentEditable={canEdit} suppressContentEditableWarning>{model.ranking === "相符" ? "随着评分增加,坏账户占比逐渐降低,模型排序能力表现稳定。" : "随着评分增加,坏账户占比未保持单调下降,中段评分区间出现反升,建议结合特征层面进一步排查。"}</p>
</section>
<section className="grid grid-cols-2 gap-6">
<Card size="sm"><CardHeader><CardTitle>(三)评分风险区分度验证</CardTitle><CardDescription>KS 分档线 40%,干预线 30%</CardDescription></CardHeader><CardContent><MetricLineChart months={[...MONITOR_MONTHS]} values={ksTrend} name={`${model.modelId} · KS`} thresholds={[{ value: 40, label: "40% 分档线", tone: "danger" }, { value: 30, label: "30% 干预线", tone: "warning" }]} /></CardContent></Card>
<Card size="sm"><CardHeader><CardTitle>(四)评分稳定性验证</CardTitle><CardDescription>PSI 使用滚动基准期</CardDescription></CardHeader><CardContent><MetricLineChart months={[...MONITOR_MONTHS]} values={psiTrend} name={`${model.modelId} · PSI`} thresholds={[{ value: 10, label: "10% 关注线", tone: "warning" }, { value: 25, label: "25% 偏移线", tone: "danger" }]} /></CardContent></Card>
</section>
{report.type === "诊断报告" && <section className="space-y-3"><h4 className="text-base font-semibold text-foreground">(五)入模特征诊断</h4><Table><TableHeader><TableRow><TableHead>特征</TableHead><TableHead>IV(当期)</TableHead><TableHead>IV 降幅</TableHead><TableHead>CSI(当期)</TableHead><TableHead>CSI 升幅</TableHead><TableHead>诊断结论</TableHead></TableRow></TableHeader><TableBody>{FEATURE_METRICS.map((feature) => <TableRow key={feature.key}><TableCell><b className="block text-foreground">{feature.name}</b><small className="font-mono text-muted-foreground">{feature.key}</small></TableCell><TableCell>{feature.iv.toFixed(3)}</TableCell><TableCell className="text-danger">-{feature.ivDrop}%</TableCell><TableCell>{feature.csi.toFixed(3)}</TableCell><TableCell className="text-warning">+{feature.csiRise}pp</TableCell><TableCell className="whitespace-normal text-muted-foreground">{feature.csiRise > 2 ? "分布迁移明显,建议核查上游数据口径" : "分布基本稳定"}</TableCell></TableRow>)}</TableBody></Table></section>}
<section className="rounded-xl bg-brand-soft p-5">
<h3 className="text-base font-semibold text-primary">三、总结</h3>
<p className="mt-2 text-sm leading-7 text-primary/80" contentEditable={canEdit} suppressContentEditableWarning>
本次模型{report.type === "诊断报告" ? "诊断" : "监控"}显示:模型 KS {model.ks.toFixed(2)}%PSI {model.psi.toFixed(2)}%{model.ranking === "相符" ? "排序性保持稳定。" : "排序性存在反转,建议启动模型微调或重构评估。"}
</p>
</section>
<p className="rounded-xl border border-border p-4 text-xs leading-5 text-muted-foreground">报告中的指标由平台程序取数计算,大模型仅负责文字表述与归因;正文不展示监控结果等级。当前页面为前端 Mock,保存和发送刷新后会重置。</p>
</CardContent>
</Card>
</>
) : <Card><CardContent className="py-16 text-center text-sm text-muted-foreground">当前筛选条件下没有报告</CardContent></Card>}
</div>
</section>
);
}