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 { usePersistentState } from "~/hooks/use-persistent-state"; 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 { isOperationsActionVisibleForRole, useOperationsRole } 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 ; } export default function RuleManagementPage() { const { models } = useOperationsData(); const operationsRole = useOperationsRole(); const canManage = isOperationsActionVisibleForRole(operationsRole, "rules:manage"); const initialConfigs: Record = { all: { ...BASE_THRESHOLDS }, std: null, bai: null, big: null, afd: null }; const [category, setCategory] = useState("all"); const [configs, setConfigs] = usePersistentState("a-card-rule-configs", initialConfigs); const [simulation, setSimulation] = useState({ ...BASE_THRESHOLDS }); const [versions, setVersions] = usePersistentState("a-card-rule-versions", 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}`; setVersions((current) => [{ version, category: category === "all" ? "全部大类" : MODEL_CATEGORIES.find((item) => item.id === category)?.name ?? category, author: "管理员 王芳", createdAt: "2026-08-31 10:30", current: true, note: "手动发布(Mock)" }, ...current.map((item) => ({ ...item, current: false }))]); toast.success(`已模拟发布规则 ${version}`); }; return (
发布新版本 : undefined} /> 规则矩阵排序性 × KS × PSI × KS 环比降幅 ({ label: item.name, value: item.id }))]} onChange={selectCategory} /> #排序性KSPSIKS环比降幅异常等级监控结果分类命中原因应对机制{MONITORING_RULES.map((rule) => {rule.id}{rule.ranking}{rule.ksBand}{rule.psiBand}{rule.dropBand}{rule.reason}{rule.abnormal === "二级" && 近 6 个月累计 4 次及以上升级为 C}{rule.action})}
{category === "all" ? "基线配置" : configs[category] ? "已定制" : "继承基线"}{canManage && category !== "all" && (configs[category] ? : )}
updateCut("ks", value)} /> updateCut("psiLow", value)} /> updateCut("psiHigh", value)} /> updateCut("ksDrop", value)} /> updateCut("interventionKs", value)} /> updateCut("interventionPsi", value)} />

额外干预提醒:KS < {activeCut.interventionKs}% 或 PSI > {activeCut.interventionPsi}% 时,额外提醒建模团队主动干预。

版本管理发布、留痕与一键回滚 {versions.map((version) =>
{version.version}{version.category}{version.note}{version.author} · {version.createdAt}{version.current ? 当前生效 : canManage ? : 历史}
)}
{canManage && 阈值影响测算调整切点后,按最近一期数据查看告警银行数
setSimulation((current) => ({ ...current, ks: value }))} /> setSimulation((current) => ({ ...current, psiHigh: value }))} /> setSimulation((current) => ({ ...current, ksDrop: value }))} /> setSimulation((current) => ({ ...current, interventionKs: value }))} />
{[ ["告警银行数", 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]) =>
{label}
{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)}
)}
{changed.length ? 银行模型IDKSPSI现行测算{changed.map((item) => {item.model.bank}{item.model.modelId}{item.model.ks.toFixed(2)}%{item.model.psi.toFixed(2)}%{item.from === "—" ? "—" : }{item.to === "—" ? "—" : })}
:
按当前测算切点,判级结果与现行一致。
}
{changed.length > 0 && }
}
); }