- 新增 model_deploy 与 model_operations 双库查询,支持模型列表、模型详情、单月监控结果和运维工作台真实接口。 - 关闭运维模块生产 Mock 数据,补充缺表/空数据降级、工作台空状态和首条纵向链路测试。 - 按业务团队、模型团队、管理员权限矩阵接入菜单、页面、操作按钮和后端接口权限校验,支持 business_team 角色。 - 更新工作台布局、深色欢迎卡片、全宽页面适配、顶部回退,以及权限分组展示。 - 新增架构实现基线、周目标完成情况和角色权限矩阵初始化 SQL 文档。
160 lines
13 KiB
TypeScript
160 lines
13 KiB
TypeScript
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>
|
||
);
|
||
}
|