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

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

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

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

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

160 lines
18 KiB
TypeScript

import { useMemo, useRef, useState } from "react";
import { ArrowRight, Download, TrendingDown, TrendingUp } from "lucide-react";
import { useNavigate, useSearchParams } from "react-router";
import { Button } from "~/components/ui/button";
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
import { Input } from "~/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
import { exportRowsToExcel } from "~/lib/exportExcel";
import { CategoryCardRail } from "./CategoryCardRail";
import { AbnormalBadge, FilterSelect, GradeBadge, MetricLineChart, OperationsPageHeader } from "./OperationsUi";
import { useOperationsData } from "./OperationsDataContext";
import {
MODEL_CATEGORIES,
MONITOR_MONTHS,
abnormalLevelOf,
average,
averageIterationCycle,
categoryName,
categoryTrend,
gradeOf,
latestIterationDate,
modelIterationCycleMonths,
modelsTrend,
monthsBetween,
type ModelCategoryId,
type ModelGrade,
type ModelRecord,
} from "./modelData";
type BankCategoryCard = {
id: string;
name: string;
models: ModelRecord[];
modelCount: number;
versions: number;
latestIteration: string;
averageCycle: number | null;
ownKs: number;
ownPsi: number;
peerKs: number;
peerPsi: number;
grades: Record<ModelGrade, number>;
};
function isCategoryId(value: string | null): value is ModelCategoryId {
return MODEL_CATEGORIES.some((category) => category.id === value);
}
function changeOf(series: number[]): number {
if (series.length < 2) return 0;
return Number(((series.at(-1) ?? 0) - (series.at(-2) ?? 0)).toFixed(2));
}
function TrendSummary({ own, peer }: { own: number[]; peer: number[] }) {
const values = [
["本行均值", `${(own.at(-1) ?? 0).toFixed(2)}%`, "text-primary"],
["本行较上月", `${changeOf(own) >= 0 ? "+" : ""}${changeOf(own).toFixed(2)}pp`, "text-primary"],
["同业均值", `${(peer.at(-1) ?? 0).toFixed(2)}%`, "text-warning"],
["同业较上月", `${changeOf(peer) >= 0 ? "+" : ""}${changeOf(peer).toFixed(2)}pp`, "text-warning"],
];
return <div className="mb-4 grid grid-cols-4 gap-2">{values.map(([label, value, tone]) => <div className="rounded-xl bg-muted/55 px-3 py-2" key={label}><span className="block text-2xs text-muted-foreground">{label}</span><strong className={`mt-1 block whitespace-nowrap text-sm tabular-nums ${tone}`}>{value}</strong></div>)}</div>;
}
function BankDeviationTable({ metric, models, averageValue, onOpenDetail }: {
metric: "KS" | "PSI";
models: ModelRecord[];
averageValue: number;
onOpenDetail: (modelId: string) => void;
}) {
const isPsi = metric === "PSI";
const rows = [...models].filter((model) => isPsi ? model.psi > averageValue : model.ks < averageValue).sort((left, right) => isPsi ? right.psi - left.psi : left.ks - right.ks);
return (
<Card size="sm">
<CardHeader className="border-b border-border"><CardTitle className="flex items-center gap-2">{isPsi ? <TrendingUp className="size-4 text-warning" /> : <TrendingDown className="size-4 text-warning" />}{metric} {isPsi ? "高于" : "低于"}同业平均值</CardTitle><CardDescription>同业平均 {averageValue.toFixed(2)}% · {rows.length} </CardDescription><CardAction><Button variant="outline" size="xs" onClick={() => void exportRowsToExcel({ fileName: `银行_${metric}_${isPsi ? "高于" : "低于"}同业平均值`, sheetName: `${metric}同业对比`, headers: ["模型名称", "模型版本", "模型ID", `当月${metric}`, "监控结果等级", "月份", "最近处理", "上次处理建议"], rows: rows.map((model) => [model.name, model.version, model.modelId, isPsi ? model.psi : model.ks, gradeOf(model), "2026-07", model.processedAt ?? "未处理", model.previousAdvice]) })}><Download />导出 Excel</Button></CardAction></CardHeader>
<CardContent className="px-0"><Table><TableHeader><TableRow><TableHead>模型 / 版本</TableHead><TableHead>当月 {metric}</TableHead><TableHead>等级 / 月份</TableHead><TableHead>最近处理</TableHead><TableHead>上次处理建议</TableHead></TableRow></TableHeader><TableBody>{rows.length ? rows.map((model) => { const value = isPsi ? model.psi : model.ks; return <TableRow key={`${metric}-${model.modelId}`}><TableCell><strong className="block">{model.name}</strong><small className="font-mono text-muted-foreground">{model.version} · {model.modelId}</small></TableCell><TableCell><strong className="block tabular-nums">{value.toFixed(2)}%</strong><small className="text-warning">{isPsi ? "↑" : "↓"} {Math.abs(value - averageValue).toFixed(2)}pp</small></TableCell><TableCell><span className="flex items-center gap-2"><GradeBadge grade={gradeOf(model)} onClick={() => onOpenDetail(model.modelId)} /><span className="font-mono text-xs text-muted-foreground">2026-07</span></span></TableCell><TableCell>{model.processedAt ?? "未处理"}</TableCell><TableCell className="min-w-48 whitespace-normal text-muted-foreground">{model.previousAdvice}</TableCell></TableRow>; }) : <TableRow><TableCell className="h-28 text-center text-muted-foreground" colSpan={5}>当前没有偏离同业平均值的模型</TableCell></TableRow>}</TableBody></Table></CardContent>
</Card>
);
}
export default function BankOverviewPage() {
const navigate = useNavigate();
const { models } = useOperationsData();
const [searchParams, setSearchParams] = useSearchParams();
const trendRef = useRef<HTMLDivElement>(null);
const banks = useMemo(() => [...new Set(models.map((model) => model.bank))], [models]);
const initialBank = banks.includes(searchParams.get("bank") ?? "") ? searchParams.get("bank") as string : banks[0] ?? "";
const initialCategory = isCategoryId(searchParams.get("category")) ? searchParams.get("category") as ModelCategoryId : "std";
const [bank, setBank] = useState(initialBank);
const [category, setCategory] = useState<ModelCategoryId>(initialCategory);
const [range, setRange] = useState("6");
const [fromMonth, setFromMonth] = useState("2026-02");
const [toMonth, setToMonth] = useState("2026-07");
const [drilldown, setDrilldown] = useState<{ category: ModelCategoryId; grade: ModelGrade; models: ModelRecord[] } | null>(null);
const months = range === "custom" ? monthsBetween(fromMonth, toMonth) : [...MONITOR_MONTHS];
const selectedModels = models.filter((model) => model.bank === bank && model.category === category && model.status !== "下线");
const peerModels = models.filter((model) => model.category === category && model.status !== "下线");
const ownKs = average(selectedModels.map((model) => model.ks));
const ownPsi = average(selectedModels.map((model) => model.psi));
const peerKs = average(peerModels.map((model) => model.ks));
const peerPsi = average(peerModels.map((model) => model.psi));
const ownKsSeries = modelsTrend(selectedModels, "ks", months);
const ownPsiSeries = modelsTrend(selectedModels, "psi", months);
const peerKsSeries = categoryTrend(category, "ks", months, models);
const peerPsiSeries = categoryTrend(category, "psi", months, models);
const cards = useMemo<BankCategoryCard[]>(() => {
const realCards = MODEL_CATEGORIES.map((item) => {
const bankModels = models.filter((model) => model.bank === bank && model.category === item.id && model.status !== "下线");
const allPeers = models.filter((model) => model.category === item.id && model.status !== "下线");
const grades = bankModels.reduce<Record<ModelGrade, number>>((result, model) => ({ ...result, [gradeOf(model)]: result[gradeOf(model)] + 1 }), { A: 0, B: 0, C: 0 });
return { ...item, models: bankModels, modelCount: bankModels.length, versions: new Set(bankModels.map((model) => model.version)).size, latestIteration: latestIterationDate(bankModels), averageCycle: averageIterationCycle(bankModels), ownKs: average(bankModels.map((model) => model.ks)), ownPsi: average(bankModels.map((model) => model.psi)), peerKs: average(allPeers.map((model) => model.ks)), peerPsi: average(allPeers.map((model) => model.psi)), grades };
});
return realCards;
}, [bank, models]);
const syncParams = (nextBank: string, nextCategory: ModelCategoryId) => setSearchParams({ bank: nextBank, category: nextCategory });
const selectBank = (value: string) => { setBank(value); syncParams(value, category); };
const selectCategory = (value: ModelCategoryId, scroll = false) => { setCategory(value); syncParams(bank, value); if (scroll) requestAnimationFrame(() => trendRef.current?.scrollIntoView({ behavior: "smooth", block: "start" })); };
const openGrade = (item: BankCategoryCard, grade: ModelGrade) => {
const gradeModels = item.models.filter((model) => gradeOf(model) === grade);
if (gradeModels.length === 1) return navigate(`/operations/monitoring/${gradeModels[0]?.modelId}`);
setDrilldown({ category: item.id as ModelCategoryId, grade, models: gradeModels });
};
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="按银行和模型大类查看本行表现,并与同业均值对照。" actions={<Button variant="outline" onClick={() => navigate("/operations/deployed-models")}>查看已上线模型 <ArrowRight /></Button>} />
<Card size="sm"><CardContent className="flex items-end gap-4"><FilterSelect className="w-56" label="筛选银行" value={bank} allLabel="请选择银行" options={banks} onChange={selectBank} /><p className="pb-2 text-sm text-muted-foreground">选择银行后,卡片展示本行指标及同业对比</p></CardContent></Card>
<CategoryCardRail>
{cards.map((item) => {
const total = item.grades.A + item.grades.B + item.grades.C || 1;
const selected = item.id === category;
const hasModels = item.modelCount > 0;
const activate = () => selectCategory(item.id as ModelCategoryId, true);
return <Card className={`cursor-pointer transition-all hover:-translate-y-0.5 hover:ring-1 hover:ring-primary/40 ${selected ? "ring-2 ring-primary" : ""}`} key={item.id} role="button" size="sm" tabIndex={0} onClick={activate} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") activate(); }}><CardHeader><CardTitle className="flex items-center gap-2">{item.name}<span className="rounded-lg bg-muted px-2 py-1 text-2xs font-medium text-muted-foreground">银行-{bank || "全部"}</span></CardTitle></CardHeader><CardContent className="space-y-3">
<dl className="grid grid-cols-3 gap-3">{[["银行数", hasModels ? 1 : 0], ["模型数", item.modelCount], ["版本数", item.versions]].map(([label, value]) => <div key={label}><dt className="whitespace-nowrap text-2xs text-ink-caption">{label}</dt><dd className="mt-1 text-lg font-bold tabular-nums">{value}</dd></div>)}</dl>
<dl className="grid grid-cols-2 gap-3"><div><dt className="text-2xs text-ink-caption">最近迭代日期</dt><dd className="mt-1 whitespace-nowrap text-sm font-bold tabular-nums">{item.latestIteration}</dd></div><div><dt className="whitespace-nowrap text-2xs text-ink-caption">平均迭代周期</dt><dd className="mt-1 whitespace-nowrap text-lg font-bold tabular-nums">{hasModels && item.averageCycle !== null ? `${item.averageCycle.toFixed(1)}月` : "—"}</dd></div></dl>
<dl className="grid grid-cols-2 gap-3"><div><dt className="text-2xs text-ink-caption">本行平均 KS</dt><dd className="mt-1 text-xl font-bold tabular-nums">{hasModels ? `${item.ownKs.toFixed(1)}%` : "—"}</dd></div><div><dt className="text-2xs text-ink-caption">本行平均 PSI</dt><dd className="mt-1 text-xl font-bold tabular-nums">{hasModels ? `${item.ownPsi.toFixed(1)}%` : "—"}</dd></div><div><dt className="text-2xs text-warning">同业平均 KS</dt><dd className="mt-1 text-xl font-bold tabular-nums text-warning">{item.peerKs.toFixed(1)}%</dd></div><div><dt className="text-2xs text-warning">同业平均 PSI</dt><dd className="mt-1 text-xl font-bold tabular-nums text-warning">{item.peerPsi.toFixed(1)}%</dd></div></dl>
<div className="flex h-2 overflow-hidden rounded-full bg-muted"><i className="bg-success" style={{ width: `${item.grades.A / total * 100}%` }} /><i className="bg-warning" style={{ width: `${item.grades.B / total * 100}%` }} /><i className="bg-danger" style={{ width: `${item.grades.C / total * 100}%` }} /></div>
<div className="flex flex-wrap gap-2" onClick={(event) => event.stopPropagation()}>{(["A", "B", "C"] as ModelGrade[]).map((grade) => <GradeBadge grade={grade} key={grade} suffix={`${item.grades[grade]} 个模型`} onClick={() => openGrade(item, grade)} />)}</div>
</CardContent></Card>;
})}
</CategoryCardRail>
<Card ref={trendRef} className="scroll-mt-20">
<CardHeader className="border-b border-border"><CardTitle>银行指标趋势</CardTitle><CardDescription>本行均值与同业均值双线对比</CardDescription><CardAction className="flex items-end gap-2"><FilterSelect className="w-40" label="模型大类" value={category} allLabel="请选择" options={MODEL_CATEGORIES.map((item) => ({ label: item.name, value: item.id }))} onChange={(value) => { if (isCategoryId(value)) selectCategory(value); }} /><FilterSelect className="w-40" label="时间范围" value={range} allLabel="请选择" options={[{ label: "近 6 个月", value: "6" }, { label: "自定义", value: "custom" }]} onChange={setRange} />{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></>}</CardAction></CardHeader>
<CardContent className="space-y-6">{selectedModels.length ? <><div className="grid grid-cols-2 items-start gap-6"><BankDeviationTable metric="KS" models={selectedModels} averageValue={peerKs} onOpenDetail={(modelId) => navigate(`/operations/monitoring/${modelId}`)} /><BankDeviationTable metric="PSI" models={selectedModels} averageValue={peerPsi} onOpenDetail={(modelId) => navigate(`/operations/monitoring/${modelId}`)} /></div><div className="grid grid-cols-2 gap-6"><Card size="sm"><CardHeader><CardTitle>平均 KS 趋势</CardTitle><CardDescription>{bank} · {categoryName(category)} · {months[0]} {months.at(-1)}</CardDescription></CardHeader><CardContent><TrendSummary own={ownKsSeries} peer={peerKsSeries} /><MetricLineChart months={months} values={ownKsSeries} name={`${bank} · ${categoryName(category)} 平均 KS`} comparison={{ name: "同业平均 KS", values: peerKsSeries, tone: "warning" }} thresholds={[{ value: 40, label: "40% 分档线", tone: "danger" }, { value: 30, label: "30% 干预线", tone: "warning" }]} /></CardContent></Card><Card size="sm"><CardHeader><CardTitle>平均 PSI 趋势</CardTitle><CardDescription>{bank} · {categoryName(category)} · {months[0]} {months.at(-1)}</CardDescription></CardHeader><CardContent><TrendSummary own={ownPsiSeries} peer={peerPsiSeries} /><MetricLineChart months={months} values={ownPsiSeries} name={`${bank} · ${categoryName(category)} 平均 PSI`} comparison={{ name: "同业平均 PSI", values: peerPsiSeries, tone: "warning" }} thresholds={[{ value: 10, label: "10% 关注线", tone: "warning" }, { value: 25, label: "25% 偏移线", tone: "danger" }]} /></CardContent></Card></div></> : <div className="rounded-xl bg-muted/50 p-8 text-center text-sm text-muted-foreground">{bank}当前暂无{categoryName(category)}在用模型,请选择其他大类。</div>}</CardContent>
</Card>
</div>
<Dialog open={Boolean(drilldown)} onOpenChange={(open) => { if (!open) setDrilldown(null); }}><DialogContent className="sm:max-w-6xl"><DialogHeader><DialogTitle>{drilldown ? `${bank} · ${categoryName(drilldown.category)} · ${drilldown.grade} 等级模型` : "等级模型"}</DialogTitle><DialogDescription> {drilldown?.models.length ?? 0} 个模型,支持逐行进入监控详情。</DialogDescription></DialogHeader><Table><TableHeader><TableRow><TableHead>模型ID</TableHead><TableHead>最近迭代日期</TableHead><TableHead>平均迭代周期</TableHead><TableHead>排序性</TableHead><TableHead>KS</TableHead><TableHead>PSI</TableHead><TableHead>KS环比降幅</TableHead><TableHead>异常等级</TableHead><TableHead className="text-right">操作</TableHead></TableRow></TableHeader><TableBody>{drilldown?.models.length ? drilldown.models.map((model) => { const cycle = modelIterationCycleMonths(model); return <TableRow key={model.modelId}><TableCell className="font-mono text-xs">{model.modelId}</TableCell><TableCell>{model.iteratedAt}</TableCell><TableCell>{cycle === null ? "—" : `${cycle} 个月`}</TableCell><TableCell>{model.ranking}</TableCell><TableCell>{model.ks.toFixed(2)}%</TableCell><TableCell>{model.psi.toFixed(2)}%</TableCell><TableCell>{model.ksDrop.toFixed(2)}%</TableCell><TableCell><AbnormalBadge level={abnormalLevelOf(model)} /></TableCell><TableCell className="text-right"><Button variant="link" size="sm" onClick={() => { setDrilldown(null); navigate(`/operations/monitoring/${model.modelId}`); }}>监控详情</Button></TableCell></TableRow>; }) : <TableRow><TableCell className="h-28 text-center text-muted-foreground" colSpan={9}>该等级下暂无模型</TableCell></TableRow>}</TableBody></Table><DialogFooter><Button variant="outline" onClick={() => setDrilldown(null)}>关闭</Button><Button onClick={() => { if (drilldown) selectCategory(drilldown.category, true); setDrilldown(null); }}>查看对应指标趋势</Button></DialogFooter></DialogContent></Dialog>
</section>
);
}