- 删除模型大类概览和细分银行概览中原型未包含的过渡跳转按钮,保留卡片点击、筛选、指标趋势和详情入口。 - 移除运维系统配置页中的模型平台新增页面依赖、登录角色来源等内部说明,仅保留实际配置内容。 - 将开发工作台访问控制统一收敛到 dashboard:view,业务团队仅保留运维工作台权限;直接访问开发工作台时自动回到运维工作台。 - 补充架构实现基线、外部写入协议、运维 API 清单、三角色权限验收说明和首条纵向链路联调手册。 - 按当前实现更新架构变更记录和本周目标完成情况。
164 lines
16 KiB
TypeScript
164 lines
16 KiB
TypeScript
import { useMemo, useRef, useState } from "react";
|
|
import { 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 { FilterSelect, GradeBadge, MetricLineChart, OperationsPageHeader } from "./OperationsUi";
|
|
import { useOperationsData } from "./OperationsDataContext";
|
|
import {
|
|
MODEL_CATEGORIES,
|
|
MONITOR_MONTHS,
|
|
abnormalLevelOf,
|
|
average,
|
|
averageIterationCycle,
|
|
categoryName,
|
|
categoryTrend,
|
|
gradeOf,
|
|
latestIterationDate,
|
|
modelIterationCycleMonths,
|
|
monthsBetween,
|
|
type ModelCategoryId,
|
|
type ModelGrade,
|
|
type ModelRecord,
|
|
} from "./modelData";
|
|
|
|
type CategoryCardItem = {
|
|
id: string;
|
|
name: string;
|
|
models: ModelRecord[];
|
|
banks: number;
|
|
versions: number;
|
|
latestIteration: string;
|
|
averageCycle: number | null;
|
|
averageKs: number;
|
|
averagePsi: number;
|
|
gradeCounts: Record<ModelGrade, number>;
|
|
};
|
|
|
|
function isCategoryId(value: string | null): value is ModelCategoryId {
|
|
return MODEL_CATEGORIES.some((category) => category.id === value);
|
|
}
|
|
|
|
function DeviationTable({ 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.bank, 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 text-foreground">{model.bank}</strong><small className="text-muted-foreground">{model.name} · {model.version}</small></TableCell><TableCell><strong className="block tabular-nums text-foreground">{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 ModelOverviewPage() {
|
|
const navigate = useNavigate();
|
|
const { models } = useOperationsData();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const trendRef = useRef<HTMLDivElement>(null);
|
|
const initialCategory = isCategoryId(searchParams.get("category")) ? searchParams.get("category") as ModelCategoryId : "std";
|
|
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 selectedName = categoryName(category);
|
|
const selectedModels = models.filter((model) => model.category === category && model.status !== "下线");
|
|
const averageKs = average(selectedModels.map((model) => model.ks));
|
|
const averagePsi = average(selectedModels.map((model) => model.psi));
|
|
const ksSeries = categoryTrend(category, "ks", months, models);
|
|
const psiSeries = categoryTrend(category, "psi", months, models);
|
|
|
|
const categoryCards = useMemo<CategoryCardItem[]>(() => {
|
|
const realCards = MODEL_CATEGORIES.map((item) => {
|
|
const categoryModels = models.filter((model) => model.category === item.id && model.status !== "下线");
|
|
const gradeCounts = categoryModels.reduce<Record<ModelGrade, number>>((result, model) => ({ ...result, [gradeOf(model)]: result[gradeOf(model)] + 1 }), { A: 0, B: 0, C: 0 });
|
|
return { ...item, models: categoryModels, banks: new Set(categoryModels.map((model) => model.bank)).size, versions: new Set(categoryModels.map((model) => model.version)).size, latestIteration: latestIterationDate(categoryModels), averageCycle: averageIterationCycle(categoryModels), averageKs: average(categoryModels.map((model) => model.ks)), averagePsi: average(categoryModels.map((model) => model.psi)), gradeCounts };
|
|
});
|
|
return realCards;
|
|
}, [models]);
|
|
|
|
const selectCategory = (value: ModelCategoryId, scroll = false) => {
|
|
setCategory(value);
|
|
setSearchParams({ category: value });
|
|
if (scroll) requestAnimationFrame(() => trendRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }));
|
|
};
|
|
|
|
const openGrade = (item: CategoryCardItem, grade: ModelGrade) => {
|
|
setDrilldown({ category: item.id as ModelCategoryId, grade, models: item.models.filter((model) => gradeOf(model) === grade) });
|
|
};
|
|
|
|
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="按模型大类汇总全部银行;点击大类卡片可定位到对应指标趋势。" />
|
|
|
|
<CategoryCardRail>
|
|
{categoryCards.map((item) => {
|
|
const total = item.gradeCounts.A + item.gradeCounts.B + item.gradeCounts.C || 1;
|
|
const selected = item.id === category;
|
|
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">全部银行</span></CardTitle></CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<dl className="grid grid-cols-3 gap-3">{[["银行数", item.banks], ["模型数", item.models.length], ["版本数", 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">{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">{item.averageKs.toFixed(1)}%</dd></div><div><dt className="text-2xs text-ink-caption">平均 PSI</dt><dd className="mt-1 text-xl font-bold tabular-nums">{item.averagePsi.toFixed(1)}%</dd></div></dl>
|
|
<div className="flex h-2 overflow-hidden rounded-full bg-muted" aria-label="模型监控结果等级分布"><i className="bg-success" style={{ width: `${item.gradeCounts.A / total * 100}%` }} /><i className="bg-warning" style={{ width: `${item.gradeCounts.B / total * 100}%` }} /><i className="bg-danger" style={{ width: `${item.gradeCounts.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.gradeCounts[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="模型大类" allLabel="请选择" value={category} options={MODEL_CATEGORIES.map((item) => ({ label: item.name, value: item.id }))} onChange={(value) => { if (isCategoryId(value)) selectCategory(value); }} /><FilterSelect className="w-40" label="时间范围" allLabel="请选择" value={range} 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">
|
|
<div className="grid grid-cols-2 items-start gap-6"><DeviationTable metric="KS" models={selectedModels} averageValue={averageKs} onOpenDetail={(modelId) => navigate(`/operations/monitoring/${modelId}`)} /><DeviationTable metric="PSI" models={selectedModels} averageValue={averagePsi} onOpenDetail={(modelId) => navigate(`/operations/monitoring/${modelId}`)} /></div>
|
|
<div className="grid grid-cols-2 gap-6"><Card size="sm"><CardHeader><CardTitle>平均 KS 趋势</CardTitle><CardDescription>{selectedName} · {months[0]} 至 {months.at(-1)} · 最新一期 {averageKs.toFixed(2)}%</CardDescription></CardHeader><CardContent><MetricLineChart months={months} values={ksSeries} name={`${selectedName} 平均 KS`} thresholds={[{ value: 40, label: "40% 分档线", tone: "danger" }, { value: 30, label: "30% 干预线", tone: "warning" }]} /></CardContent></Card><Card size="sm"><CardHeader><CardTitle>平均 PSI 趋势</CardTitle><CardDescription>{selectedName} · {months[0]} 至 {months.at(-1)} · 最新一期 {averagePsi.toFixed(2)}%</CardDescription></CardHeader><CardContent><MetricLineChart months={months} values={psiSeries} name={`${selectedName} 平均 PSI`} thresholds={[{ value: 10, label: "10% 关注线", tone: "warning" }, { value: 25, label: "25% 偏移线", tone: "danger" }]} /></CardContent></Card></div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Dialog open={Boolean(drilldown)} onOpenChange={(open) => { if (!open) setDrilldown(null); }}>
|
|
<DialogContent className="sm:max-w-6xl">
|
|
<DialogHeader><DialogTitle>{drilldown ? `${categoryName(drilldown.category)} · ${drilldown.grade} 等级模型` : "等级模型"}</DialogTitle><DialogDescription>共 {new Set(drilldown?.models.map((model) => model.bank)).size} 家银行、{drilldown?.models.length ?? 0} 个模型;最近迭代日期和平均迭代周期按模型展示。</DialogDescription></DialogHeader>
|
|
<Table><TableHeader><TableRow><TableHead>银行</TableHead><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>{model.bank}</TableCell><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>{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={10}>该等级下暂无模型</TableCell></TableRow>}</TableBody></Table>
|
|
<DialogFooter><Button variant="outline" onClick={() => setDrilldown(null)}>关闭</Button>{drilldown?.models.length ? <Button variant="outline" onClick={() => void exportRowsToExcel({ fileName: `${categoryName(drilldown.category)}_${drilldown.grade}等级模型`, sheetName: "等级模型", headers: ["银行", "模型ID", "最近迭代日期", "平均迭代周期", "排序性", "KS", "PSI", "KS环比降幅", "异常等级"], rows: drilldown.models.map((model) => [model.bank, model.modelId, model.iteratedAt, modelIterationCycleMonths(model) ?? "—", model.ranking, model.ks, model.psi, model.ksDrop, abnormalLevelOf(model)]) })}><Download />导出 Excel</Button> : null}<Button onClick={() => { if (drilldown) selectCategory(drilldown.category, true); setDrilldown(null); }}>查看对应指标趋势</Button></DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</section>
|
|
);
|
|
}
|