feat: add A-card operations frontend and backend foundation
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user