Files
郑龙捷 7d5e3e9a50 fix: 对齐运维页面原型并收口权限边界
- 删除模型大类概览和细分银行概览中原型未包含的过渡跳转按钮,保留卡片点击、筛选、指标趋势和详情入口。

- 移除运维系统配置页中的模型平台新增页面依赖、登录角色来源等内部说明,仅保留实际配置内容。

- 将开发工作台访问控制统一收敛到 dashboard:view,业务团队仅保留运维工作台权限;直接访问开发工作台时自动回到运维工作台。

- 补充架构实现基线、外部写入协议、运维 API 清单、三角色权限验收说明和首条纵向链路联调手册。

- 按当前实现更新架构变更记录和本周目标完成情况。
2026-09-02 17:39:36 +08:00

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>
);
}