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

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

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

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

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

160 lines
13 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 { useCanOperationsAction } 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() {
return (
<Table>
<TableHeader><TableRow><TableHead>分数区间</TableHead><TableHead>总账户数</TableHead><TableHead>账户占比</TableHead><TableHead>账户累计占比</TableHead></TableRow></TableHeader>
<TableBody><TableRow><TableCell className="h-28 text-center text-muted-foreground" colSpan={4}>暂无评分分布数据</TableCell></TableRow></TableBody>
</Table>
);
}
export default function ReportPage() {
const navigate = useNavigate();
const { models } = useOperationsData();
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 && useCanOperationsAction("report:edit");
const canExport = useCanOperationsAction("report:export");
const canSend = !isHistorical && useCanOperationsAction("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="flex w-full 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 && <>{canExport && <Button variant="outline" onClick={printReport}><Download />导出 PDF</Button>}{canEdit && <Button variant="outline" onClick={() => { updateReport(report.reportId, { status: "编辑中", synced: true }); toast.info("报告保存接口尚未接入"); }}><Save />保存草稿</Button>}{canSend && report.status !== "已发送业务团队" && <Button onClick={() => toast.info("报告发送接口尚未接入")}><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">
{[
["验证样本", "—"], ["坏样本", "—"], ["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 />
</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">报告中的指标由平台程序取数计算,大模型仅负责文字表述与归因;正文不展示监控结果等级。</p>
</CardContent>
</Card>
</>
) : <Card><CardContent className="py-16 text-center text-sm text-muted-foreground">当前筛选条件下没有报告</CardContent></Card>}
</div>
</section>
);
}