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

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

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

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

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

133 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 &lt; {activeCut.interventionKs}% PSI &gt; {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>
);
}