- 新增 model_deploy 与 model_operations 双库查询,支持模型列表、模型详情、单月监控结果和运维工作台真实接口。 - 关闭运维模块生产 Mock 数据,补充缺表/空数据降级、工作台空状态和首条纵向链路测试。 - 按业务团队、模型团队、管理员权限矩阵接入菜单、页面、操作按钮和后端接口权限校验,支持 business_team 角色。 - 更新工作台布局、深色欢迎卡片、全宽页面适配、顶部回退,以及权限分组展示。 - 新增架构实现基线、周目标完成情况和角色权限矩阵初始化 SQL 文档。
133 lines
15 KiB
TypeScript
133 lines
15 KiB
TypeScript
import { useMemo, useState } from "react";
|
||
import { Download, RotateCcw, Save, SlidersHorizontal } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
|
||
import { Button } from "~/components/ui/button";
|
||
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||
import { Input } from "~/components/ui/input";
|
||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
|
||
import { exportRowsToExcel } from "~/lib/exportExcel";
|
||
import { AbnormalBadge, FilterSelect, GradeBadge, OperationsPageHeader } from "./OperationsUi";
|
||
import { MODEL_CATEGORIES, type ModelCategoryId, type ModelGrade, type ModelRecord } from "./modelData";
|
||
import { BASE_THRESHOLDS, MONITORING_RULES, RULE_VERSIONS, type MonitoringRule, type ThresholdConfig } from "./governanceData";
|
||
import { useOperationsData } from "./OperationsDataContext";
|
||
import { useCanOperationsAction } from "./operationsRole";
|
||
|
||
type CategoryKey = "all" | ModelCategoryId;
|
||
|
||
function ruleMatches(model: ModelRecord, rule: MonitoringRule, cut: ThresholdConfig): boolean {
|
||
if (model.ranking !== rule.ranking) return false;
|
||
const ks = rule.ksBand === "<40%" ? model.ks < cut.ks : model.ks >= cut.ks;
|
||
const psi = rule.psiBand === ">10%" ? model.psi > cut.psiLow
|
||
: rule.psiBand === ">25%" ? model.psi > cut.psiHigh
|
||
: rule.psiBand === "10%-25%" ? model.psi > cut.psiLow && model.psi <= cut.psiHigh
|
||
: rule.psiBand === "<=10%" ? model.psi <= cut.psiLow
|
||
: model.psi <= cut.psiHigh;
|
||
const drop = rule.dropBand === "无条件" || (rule.dropBand === ">20%" ? model.ksDrop > cut.ksDrop : model.ksDrop <= cut.ksDrop);
|
||
return ks && psi && drop;
|
||
}
|
||
|
||
function judge(model: ModelRecord, cut: ThresholdConfig): ModelGrade | "—" {
|
||
if (model.status === "下线" || model.ranking === "—") return "—";
|
||
const rule = MONITORING_RULES.find((item) => ruleMatches(model, item, cut));
|
||
if (!rule) return "—";
|
||
if (rule.abnormal === "二级" && model.secondaryHits >= 4) return "C";
|
||
return rule.grade;
|
||
}
|
||
|
||
function NumberField({ label, value, disabled, onChange }: { label: string; value: number; disabled?: boolean; onChange: (value: number) => void }) {
|
||
return <label className="flex flex-col gap-1.5"><span className="text-xs font-medium text-ink-caption">{label}</span><Input type="number" step="0.5" disabled={disabled} value={value} onChange={(event) => onChange(Number(event.target.value))} /></label>;
|
||
}
|
||
|
||
export default function RuleManagementPage() {
|
||
const { models } = useOperationsData();
|
||
const canManage = useCanOperationsAction("rules:manage");
|
||
const initialConfigs: Record<CategoryKey, ThresholdConfig | null> = { all: { ...BASE_THRESHOLDS }, std: null, bai: null, big: null, afd: null };
|
||
const [category, setCategory] = useState<CategoryKey>("all");
|
||
const [configs, setConfigs] = useState(initialConfigs);
|
||
const [simulation, setSimulation] = useState<ThresholdConfig>({ ...BASE_THRESHOLDS });
|
||
const [versions] = useState(RULE_VERSIONS);
|
||
const activeCut = configs[category] ?? configs.all ?? BASE_THRESHOLDS;
|
||
const editable = canManage && (category === "all" || configs[category] !== null);
|
||
const scope = models.filter((model) => model.status !== "下线" && (category === "all" || model.category === category));
|
||
const before = useMemo(() => scope.map((model) => ({ model, grade: judge(model, activeCut) })), [activeCut, category]);
|
||
const after = useMemo(() => scope.map((model) => ({ model, grade: judge(model, simulation) })), [category, simulation]);
|
||
const changed = before.map((item, index) => ({ model: item.model, from: item.grade, to: after[index]?.grade ?? "—" })).filter((item) => item.from !== item.to);
|
||
const alertsBefore = before.filter((item) => item.grade === "B" || item.grade === "C");
|
||
const alertsAfter = after.filter((item) => item.grade === "B" || item.grade === "C");
|
||
const bankBefore = new Set(alertsBefore.map((item) => item.model.bank)).size;
|
||
const bankAfter = new Set(alertsAfter.map((item) => item.model.bank)).size;
|
||
|
||
const selectCategory = (value: string) => {
|
||
const next = value as CategoryKey;
|
||
setCategory(next);
|
||
setSimulation({ ...(configs[next] ?? configs.all ?? BASE_THRESHOLDS) });
|
||
};
|
||
|
||
const updateCut = (key: keyof ThresholdConfig, value: number) => {
|
||
if (!editable) return;
|
||
setConfigs((current) => ({ ...current, [category]: { ...(current[category] ?? current.all ?? BASE_THRESHOLDS), [key]: value } }));
|
||
};
|
||
|
||
const forkCategory = () => {
|
||
if (category === "all") return;
|
||
setConfigs((current) => ({ ...current, [category]: { ...(current.all ?? BASE_THRESHOLDS) } }));
|
||
toast.success("已创建大类独立切点配置");
|
||
};
|
||
|
||
const resetCategory = () => {
|
||
if (category === "all") return;
|
||
setConfigs((current) => ({ ...current, [category]: null }));
|
||
setSimulation({ ...(configs.all ?? BASE_THRESHOLDS) });
|
||
toast.success("已恢复继承基线切点");
|
||
};
|
||
|
||
const publishVersion = () => {
|
||
const version = `V${versions.length + 1}`;
|
||
void version;
|
||
toast.info("规则发布接口尚未接入");
|
||
};
|
||
|
||
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={canManage ? "维护规则矩阵、模型大类切点、版本留痕与阈值影响测算。" : "模型团队只读查看当前规则矩阵与版本记录。"} actions={canManage ? <Button onClick={publishVersion}><Save />发布新版本</Button> : undefined} />
|
||
|
||
<Card>
|
||
<CardHeader className="border-b border-border"><CardTitle>规则矩阵</CardTitle><CardDescription>排序性 × KS × PSI × KS 环比降幅</CardDescription><CardAction className="flex items-end gap-2"><FilterSelect className="w-48" label="适用大类" value={category} allLabel="请选择" options={[{ label: "全部大类", value: "all" }, ...MODEL_CATEGORIES.map((item) => ({ label: item.name, value: item.id }))]} onChange={selectCategory} /><Button variant="outline" size="sm" onClick={() => void exportRowsToExcel({ fileName: "监控等级规则", sheetName: "规则矩阵", headers: ["序号", "排序性", "KS", "PSI", "KS环比降幅", "异常等级", "监控结果分类", "命中原因", "应对机制"], rows: MONITORING_RULES.map((rule) => [rule.id, rule.ranking, rule.ksBand, rule.psiBand, rule.dropBand, rule.abnormal, rule.grade, rule.reason, rule.action]) })}><Download />导出 Excel</Button></CardAction></CardHeader>
|
||
<CardContent className="px-0">
|
||
<Table>
|
||
<TableHeader><TableRow><TableHead>#</TableHead><TableHead>排序性</TableHead><TableHead>KS</TableHead><TableHead>PSI</TableHead><TableHead>KS环比降幅</TableHead><TableHead>异常等级</TableHead><TableHead>监控结果分类</TableHead><TableHead>命中原因</TableHead><TableHead>应对机制</TableHead></TableRow></TableHeader>
|
||
<TableBody>{MONITORING_RULES.map((rule) => <TableRow key={rule.id}><TableCell className="font-mono text-xs text-muted-foreground">{rule.id}</TableCell><TableCell className={rule.ranking === "相符" ? "text-success-strong" : "text-danger"}>{rule.ranking}</TableCell><TableCell>{rule.ksBand}</TableCell><TableCell>{rule.psiBand}</TableCell><TableCell>{rule.dropBand}</TableCell><TableCell><AbnormalBadge level={rule.abnormal} /></TableCell><TableCell><GradeBadge grade={rule.grade} /></TableCell><TableCell className="min-w-56 whitespace-normal text-muted-foreground">{rule.reason}{rule.abnormal === "二级" && <span className="mt-1 block text-xs">近 6 个月累计 4 次及以上升级为 C</span>}</TableCell><TableCell className="min-w-52 whitespace-normal">{rule.action}</TableCell></TableRow>)}</TableBody>
|
||
</Table>
|
||
</CardContent>
|
||
<CardContent className="space-y-4 border-t border-border">
|
||
<div className="flex items-center gap-2"><span className={`rounded-full px-2.5 py-1 text-xs font-medium ${category === "all" || configs[category] ? "bg-brand-soft text-primary" : "bg-muted text-muted-foreground"}`}>{category === "all" ? "基线配置" : configs[category] ? "已定制" : "继承基线"}</span>{canManage && category !== "all" && (configs[category] ? <Button variant="outline" size="xs" onClick={resetCategory}>恢复继承基线</Button> : <Button variant="outline" size="xs" onClick={forkCategory}>为该大类定制切点</Button>)}</div>
|
||
<div className="grid grid-cols-6 gap-3"><NumberField label="KS 分档线 (%)" value={activeCut.ks} disabled={!editable} onChange={(value) => updateCut("ks", value)} /><NumberField label="PSI 低档线 (%)" value={activeCut.psiLow} disabled={!editable} onChange={(value) => updateCut("psiLow", value)} /><NumberField label="PSI 高档线 (%)" value={activeCut.psiHigh} disabled={!editable} onChange={(value) => updateCut("psiHigh", value)} /><NumberField label="KS 环比降幅线 (%)" value={activeCut.ksDrop} disabled={!editable} onChange={(value) => updateCut("ksDrop", value)} /><NumberField label="干预线 KS < (%)" value={activeCut.interventionKs} disabled={!editable} onChange={(value) => updateCut("interventionKs", value)} /><NumberField label="干预线 PSI > (%)" value={activeCut.interventionPsi} disabled={!editable} onChange={(value) => updateCut("interventionPsi", value)} /></div>
|
||
<p className="rounded-xl bg-warning-soft p-3 text-xs leading-5 text-foreground">额外干预提醒:KS < {activeCut.interventionKs}% 或 PSI > {activeCut.interventionPsi}% 时,额外提醒建模团队主动干预。</p>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<div className="grid grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] gap-6">
|
||
<Card>
|
||
<CardHeader><CardTitle>版本管理</CardTitle><CardDescription>发布、留痕与一键回滚</CardDescription></CardHeader>
|
||
<CardContent className="space-y-3">{versions.length ? versions.map((version) => <div className="flex items-center gap-3 rounded-xl border border-border p-4" key={version.version}><b className="text-sm text-foreground">{version.version}</b><span className="rounded-full bg-muted px-2.5 py-1 text-xs">{version.category}</span><span className="min-w-0 flex-1"><small className="block truncate text-xs text-muted-foreground">{version.note}</small><small className="text-3xs text-muted-foreground">{version.author} · {version.createdAt}</small></span>{version.current ? <span className="rounded-full bg-success-soft px-2.5 py-1 text-xs text-success-strong">当前生效</span> : canManage ? <Button variant="outline" size="xs" onClick={() => toast.info("规则回滚接口尚未接入")}>一键回滚</Button> : <span className="rounded-full bg-muted px-2.5 py-1 text-xs text-muted-foreground">历史</span>}</div>) : <div className="py-10 text-center text-sm text-muted-foreground">暂无规则版本数据</div>}</CardContent>
|
||
</Card>
|
||
|
||
{canManage && <Card>
|
||
<CardHeader><CardTitle className="flex items-center gap-2"><SlidersHorizontal className="size-5 text-primary" />阈值影响测算</CardTitle><CardDescription>调整切点后,按最近一期数据查看告警银行数</CardDescription><CardAction><Button variant="outline" size="sm" onClick={() => setSimulation({ ...activeCut })}><RotateCcw />重置切点</Button></CardAction></CardHeader>
|
||
<CardContent className="space-y-5">
|
||
<div className="grid grid-cols-4 gap-3"><NumberField label={`KS 分档线(现行 ${activeCut.ks})`} value={simulation.ks} onChange={(value) => setSimulation((current) => ({ ...current, ks: value }))} /><NumberField label={`PSI 高档线(现行 ${activeCut.psiHigh})`} value={simulation.psiHigh} onChange={(value) => setSimulation((current) => ({ ...current, psiHigh: value }))} /><NumberField label={`KS 环比降幅(现行 ${activeCut.ksDrop})`} value={simulation.ksDrop} onChange={(value) => setSimulation((current) => ({ ...current, ksDrop: value }))} /><NumberField label={`干预线 KS(现行 ${activeCut.interventionKs})`} value={simulation.interventionKs} onChange={(value) => setSimulation((current) => ({ ...current, interventionKs: value }))} /></div>
|
||
<div className="grid grid-cols-3 gap-4">{[
|
||
["告警银行数", bankBefore, bankAfter], ["告警模型数(B/C)", alertsBefore.length, alertsAfter.length], ["其中 C 等级", before.filter((item) => item.grade === "C").length, after.filter((item) => item.grade === "C").length],
|
||
].map(([label, current, simulated]) => <div className="rounded-xl bg-muted/50 p-4" key={String(label)}><span className="text-xs text-muted-foreground">{label}</span><div className="mt-2 flex items-end gap-2"><strong className="text-xl tabular-nums text-foreground">{simulated}</strong><small className={Number(simulated) > Number(current) ? "text-danger" : Number(simulated) < Number(current) ? "text-success-strong" : "text-muted-foreground"}>现行 {current} · {Number(simulated) - Number(current) > 0 ? "+" : ""}{Number(simulated) - Number(current)}</small></div></div>)}</div>
|
||
{changed.length ? <Table><TableHeader><TableRow><TableHead>银行</TableHead><TableHead>模型ID</TableHead><TableHead>KS</TableHead><TableHead>PSI</TableHead><TableHead>现行</TableHead><TableHead>测算</TableHead></TableRow></TableHeader><TableBody>{changed.map((item) => <TableRow key={item.model.modelId}><TableCell>{item.model.bank}</TableCell><TableCell className="font-mono text-xs">{item.model.modelId}</TableCell><TableCell>{item.model.ks.toFixed(2)}%</TableCell><TableCell>{item.model.psi.toFixed(2)}%</TableCell><TableCell>{item.from === "—" ? "—" : <GradeBadge grade={item.from} />}</TableCell><TableCell>{item.to === "—" ? "—" : <GradeBadge grade={item.to} />}</TableCell></TableRow>)}</TableBody></Table> : <div className="rounded-xl bg-success-soft p-4 text-sm text-success-strong">按当前测算切点,判级结果与现行一致。</div>}
|
||
<div className="flex gap-2"><Button variant="outline" size="sm" onClick={() => void exportRowsToExcel({ fileName: "阈值影响测算摘要", sheetName: "测算摘要", headers: ["指标", "现行切点", "测算切点", "变化"], rows: [["告警银行数", bankBefore, bankAfter, bankAfter - bankBefore], ["告警模型数(B/C)", alertsBefore.length, alertsAfter.length, alertsAfter.length - alertsBefore.length], ["其中 C 等级", before.filter((item) => item.grade === "C").length, after.filter((item) => item.grade === "C").length, after.filter((item) => item.grade === "C").length - before.filter((item) => item.grade === "C").length]] })}><Download />导出测算摘要</Button>{changed.length > 0 && <Button variant="outline" size="sm" onClick={() => void exportRowsToExcel({ fileName: "阈值影响测算变化清单", sheetName: "变化清单", headers: ["银行", "模型ID", "KS", "PSI", "现行", "测算"], rows: changed.map((item) => [item.model.bank, item.model.modelId, item.model.ks, item.model.psi, item.from, item.to]) })}><Download />导出变化清单</Button>}</div>
|
||
</CardContent>
|
||
</Card>}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|