Merge remote-tracking branch 'aliyun/feature/a-card-operations' into develop
This commit is contained in:
@@ -15,6 +15,7 @@ const MODULE_LABELS: Record<string, string> = {
|
||||
script: "构建脚本",
|
||||
schedule: "调度配置",
|
||||
system: "系统管理",
|
||||
operations: "运维工作台",
|
||||
};
|
||||
|
||||
/** 勾选框样式:复刻原生 checkbox 外观(白底、灰边、蓝色勾选),并对齐 permission item 的 3px 顶部偏移。 */
|
||||
|
||||
@@ -20,7 +20,7 @@ export type UserFormState = {
|
||||
username: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
role_code: "admin" | "developer";
|
||||
role_code: string;
|
||||
password: string;
|
||||
status: "active" | "disabled" | "locked";
|
||||
};
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
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 { AbnormalBadge, FilterSelect, GradeBadge, MetricLineChart, OperationsPageHeader } from "./OperationsUi";
|
||||
import { useOperationsData } from "./OperationsDataContext";
|
||||
import {
|
||||
MODEL_CATEGORIES,
|
||||
MONITOR_MONTHS,
|
||||
abnormalLevelOf,
|
||||
average,
|
||||
averageIterationCycle,
|
||||
categoryName,
|
||||
categoryTrend,
|
||||
gradeOf,
|
||||
latestIterationDate,
|
||||
modelIterationCycleMonths,
|
||||
modelsTrend,
|
||||
monthsBetween,
|
||||
type ModelCategoryId,
|
||||
type ModelGrade,
|
||||
type ModelRecord,
|
||||
} from "./modelData";
|
||||
|
||||
type BankCategoryCard = {
|
||||
id: string;
|
||||
name: string;
|
||||
models: ModelRecord[];
|
||||
modelCount: number;
|
||||
versions: number;
|
||||
latestIteration: string;
|
||||
averageCycle: number | null;
|
||||
ownKs: number;
|
||||
ownPsi: number;
|
||||
peerKs: number;
|
||||
peerPsi: number;
|
||||
grades: Record<ModelGrade, number>;
|
||||
};
|
||||
|
||||
function isCategoryId(value: string | null): value is ModelCategoryId {
|
||||
return MODEL_CATEGORIES.some((category) => category.id === value);
|
||||
}
|
||||
|
||||
function changeOf(series: number[]): number {
|
||||
if (series.length < 2) return 0;
|
||||
return Number(((series.at(-1) ?? 0) - (series.at(-2) ?? 0)).toFixed(2));
|
||||
}
|
||||
|
||||
function TrendSummary({ own, peer }: { own: number[]; peer: number[] }) {
|
||||
const values = [
|
||||
["本行均值", `${(own.at(-1) ?? 0).toFixed(2)}%`, "text-primary"],
|
||||
["本行较上月", `${changeOf(own) >= 0 ? "+" : ""}${changeOf(own).toFixed(2)}pp`, "text-primary"],
|
||||
["同业均值", `${(peer.at(-1) ?? 0).toFixed(2)}%`, "text-warning"],
|
||||
["同业较上月", `${changeOf(peer) >= 0 ? "+" : ""}${changeOf(peer).toFixed(2)}pp`, "text-warning"],
|
||||
];
|
||||
return <div className="mb-4 grid grid-cols-4 gap-2">{values.map(([label, value, tone]) => <div className="rounded-xl bg-muted/55 px-3 py-2" key={label}><span className="block text-2xs text-muted-foreground">{label}</span><strong className={`mt-1 block whitespace-nowrap text-sm tabular-nums ${tone}`}>{value}</strong></div>)}</div>;
|
||||
}
|
||||
|
||||
function BankDeviationTable({ 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.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">{model.name}</strong><small className="font-mono text-muted-foreground">{model.version} · {model.modelId}</small></TableCell><TableCell><strong className="block tabular-nums">{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 BankOverviewPage() {
|
||||
const navigate = useNavigate();
|
||||
const { models } = useOperationsData();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const trendRef = useRef<HTMLDivElement>(null);
|
||||
const banks = useMemo(() => [...new Set(models.map((model) => model.bank))], [models]);
|
||||
const initialBank = banks.includes(searchParams.get("bank") ?? "") ? searchParams.get("bank") as string : banks[0] ?? "";
|
||||
const initialCategory = isCategoryId(searchParams.get("category")) ? searchParams.get("category") as ModelCategoryId : "std";
|
||||
const [bank, setBank] = useState(initialBank);
|
||||
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 selectedModels = models.filter((model) => model.bank === bank && model.category === category && model.status !== "下线");
|
||||
const peerModels = models.filter((model) => model.category === category && model.status !== "下线");
|
||||
const ownKs = average(selectedModels.map((model) => model.ks));
|
||||
const ownPsi = average(selectedModels.map((model) => model.psi));
|
||||
const peerKs = average(peerModels.map((model) => model.ks));
|
||||
const peerPsi = average(peerModels.map((model) => model.psi));
|
||||
const ownKsSeries = modelsTrend(selectedModels, "ks", months);
|
||||
const ownPsiSeries = modelsTrend(selectedModels, "psi", months);
|
||||
const peerKsSeries = categoryTrend(category, "ks", months, models);
|
||||
const peerPsiSeries = categoryTrend(category, "psi", months, models);
|
||||
|
||||
const cards = useMemo<BankCategoryCard[]>(() => {
|
||||
const realCards = MODEL_CATEGORIES.map((item) => {
|
||||
const bankModels = models.filter((model) => model.bank === bank && model.category === item.id && model.status !== "下线");
|
||||
const allPeers = models.filter((model) => model.category === item.id && model.status !== "下线");
|
||||
const grades = bankModels.reduce<Record<ModelGrade, number>>((result, model) => ({ ...result, [gradeOf(model)]: result[gradeOf(model)] + 1 }), { A: 0, B: 0, C: 0 });
|
||||
return { ...item, models: bankModels, modelCount: bankModels.length, versions: new Set(bankModels.map((model) => model.version)).size, latestIteration: latestIterationDate(bankModels), averageCycle: averageIterationCycle(bankModels), ownKs: average(bankModels.map((model) => model.ks)), ownPsi: average(bankModels.map((model) => model.psi)), peerKs: average(allPeers.map((model) => model.ks)), peerPsi: average(allPeers.map((model) => model.psi)), grades };
|
||||
});
|
||||
return realCards;
|
||||
}, [bank, models]);
|
||||
|
||||
const syncParams = (nextBank: string, nextCategory: ModelCategoryId) => setSearchParams({ bank: nextBank, category: nextCategory });
|
||||
const selectBank = (value: string) => { setBank(value); syncParams(value, category); };
|
||||
const selectCategory = (value: ModelCategoryId, scroll = false) => { setCategory(value); syncParams(bank, value); if (scroll) requestAnimationFrame(() => trendRef.current?.scrollIntoView({ behavior: "smooth", block: "start" })); };
|
||||
const openGrade = (item: BankCategoryCard, grade: ModelGrade) => {
|
||||
const gradeModels = item.models.filter((model) => gradeOf(model) === grade);
|
||||
if (gradeModels.length === 1) return navigate(`/operations/monitoring/${gradeModels[0]?.modelId}`);
|
||||
setDrilldown({ category: item.id as ModelCategoryId, grade, models: gradeModels });
|
||||
};
|
||||
|
||||
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="按银行和模型大类查看本行表现,并与同业均值对照。" />
|
||||
|
||||
<Card size="sm"><CardContent className="flex items-end gap-4"><FilterSelect className="w-56" label="筛选银行" value={bank} allLabel="请选择银行" options={banks} onChange={selectBank} /><p className="pb-2 text-sm text-muted-foreground">选择银行后,卡片展示本行指标及同业对比</p></CardContent></Card>
|
||||
|
||||
<CategoryCardRail>
|
||||
{cards.map((item) => {
|
||||
const total = item.grades.A + item.grades.B + item.grades.C || 1;
|
||||
const selected = item.id === category;
|
||||
const hasModels = item.modelCount > 0;
|
||||
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">银行-{bank || "全部"}</span></CardTitle></CardHeader><CardContent className="space-y-3">
|
||||
<dl className="grid grid-cols-3 gap-3">{[["银行数", hasModels ? 1 : 0], ["模型数", item.modelCount], ["版本数", 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">{hasModels && 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">{hasModels ? `${item.ownKs.toFixed(1)}%` : "—"}</dd></div><div><dt className="text-2xs text-ink-caption">本行平均 PSI</dt><dd className="mt-1 text-xl font-bold tabular-nums">{hasModels ? `${item.ownPsi.toFixed(1)}%` : "—"}</dd></div><div><dt className="text-2xs text-warning">同业平均 KS</dt><dd className="mt-1 text-xl font-bold tabular-nums text-warning">{item.peerKs.toFixed(1)}%</dd></div><div><dt className="text-2xs text-warning">同业平均 PSI</dt><dd className="mt-1 text-xl font-bold tabular-nums text-warning">{item.peerPsi.toFixed(1)}%</dd></div></dl>
|
||||
<div className="flex h-2 overflow-hidden rounded-full bg-muted"><i className="bg-success" style={{ width: `${item.grades.A / total * 100}%` }} /><i className="bg-warning" style={{ width: `${item.grades.B / total * 100}%` }} /><i className="bg-danger" style={{ width: `${item.grades.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.grades[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="模型大类" value={category} allLabel="请选择" options={MODEL_CATEGORIES.map((item) => ({ label: item.name, value: item.id }))} onChange={(value) => { if (isCategoryId(value)) selectCategory(value); }} /><FilterSelect className="w-40" label="时间范围" value={range} allLabel="请选择" 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">{selectedModels.length ? <><div className="grid grid-cols-2 items-start gap-6"><BankDeviationTable metric="KS" models={selectedModels} averageValue={peerKs} onOpenDetail={(modelId) => navigate(`/operations/monitoring/${modelId}`)} /><BankDeviationTable metric="PSI" models={selectedModels} averageValue={peerPsi} onOpenDetail={(modelId) => navigate(`/operations/monitoring/${modelId}`)} /></div><div className="grid grid-cols-2 gap-6"><Card size="sm"><CardHeader><CardTitle>平均 KS 趋势</CardTitle><CardDescription>{bank} · {categoryName(category)} · {months[0]} 至 {months.at(-1)}</CardDescription></CardHeader><CardContent><TrendSummary own={ownKsSeries} peer={peerKsSeries} /><MetricLineChart months={months} values={ownKsSeries} name={`${bank} · ${categoryName(category)} 平均 KS`} comparison={{ name: "同业平均 KS", values: peerKsSeries, tone: "warning" }} thresholds={[{ value: 40, label: "40% 分档线", tone: "danger" }, { value: 30, label: "30% 干预线", tone: "warning" }]} /></CardContent></Card><Card size="sm"><CardHeader><CardTitle>平均 PSI 趋势</CardTitle><CardDescription>{bank} · {categoryName(category)} · {months[0]} 至 {months.at(-1)}</CardDescription></CardHeader><CardContent><TrendSummary own={ownPsiSeries} peer={peerPsiSeries} /><MetricLineChart months={months} values={ownPsiSeries} name={`${bank} · ${categoryName(category)} 平均 PSI`} comparison={{ name: "同业平均 PSI", values: peerPsiSeries, tone: "warning" }} thresholds={[{ value: 10, label: "10% 关注线", tone: "warning" }, { value: 25, label: "25% 偏移线", tone: "danger" }]} /></CardContent></Card></div></> : <div className="rounded-xl bg-muted/50 p-8 text-center text-sm text-muted-foreground">{bank}当前暂无{categoryName(category)}在用模型,请选择其他大类。</div>}</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Dialog open={Boolean(drilldown)} onOpenChange={(open) => { if (!open) setDrilldown(null); }}><DialogContent className="sm:max-w-6xl"><DialogHeader><DialogTitle>{drilldown ? `${bank} · ${categoryName(drilldown.category)} · ${drilldown.grade} 等级模型` : "等级模型"}</DialogTitle><DialogDescription>共 {drilldown?.models.length ?? 0} 个模型,支持逐行进入监控详情。</DialogDescription></DialogHeader><Table><TableHeader><TableRow><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 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><AbnormalBadge level={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={9}>该等级下暂无模型</TableCell></TableRow>}</TableBody></Table><DialogFooter><Button variant="outline" onClick={() => setDrilldown(null)}>关闭</Button><Button onClick={() => { if (drilldown) selectCategory(drilldown.category, true); setDrilldown(null); }}>查看对应指标趋势</Button></DialogFooter></DialogContent></Dialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Children, type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
export function CategoryCardRail({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const items = Children.toArray(children);
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const [rowHeight, setRowHeight] = useState<number>();
|
||||
const [overflowing, setOverflowing] = useState(false);
|
||||
const [atTop, setAtTop] = useState(true);
|
||||
const [atBottom, setAtBottom] = useState(false);
|
||||
|
||||
const updateState = useCallback(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
const max = Math.max(0, viewport.scrollHeight - viewport.clientHeight);
|
||||
setOverflowing(max > 2);
|
||||
setAtTop(viewport.scrollTop <= 2);
|
||||
setAtBottom(viewport.scrollTop >= max - 2);
|
||||
}, []);
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const first = trackRef.current?.querySelector<HTMLElement>("[data-rail-card]");
|
||||
if (!first) return;
|
||||
setRowHeight(Math.ceil(first.getBoundingClientRect().height + 2));
|
||||
requestAnimationFrame(updateState);
|
||||
}, [updateState]);
|
||||
|
||||
useEffect(() => {
|
||||
measure();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(measure);
|
||||
if (trackRef.current) observer.observe(trackRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [items.length, measure]);
|
||||
|
||||
const scrollRow = (direction: -1 | 1) => {
|
||||
const viewport = viewportRef.current;
|
||||
const cards = trackRef.current?.querySelectorAll<HTMLElement>("[data-rail-card]");
|
||||
if (!viewport || !cards?.length) return;
|
||||
const absoluteRows = [...new Set([...cards].map((card) => Math.round(card.offsetTop)))].sort((a, b) => a - b);
|
||||
const origin = absoluteRows[0] ?? 0;
|
||||
const rows = absoluteRows.map((top) => top - origin);
|
||||
const current = viewport.scrollTop;
|
||||
const target = direction > 0
|
||||
? rows.find((top) => top > current + 3) ?? rows.at(-1) ?? 0
|
||||
: [...rows].reverse().find((top) => top < current - 3) ?? rows[0] ?? 0;
|
||||
viewport.scrollTo({ top: target, behavior: "smooth" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("min-w-0", className)} data-slot="category-card-rail">
|
||||
<div
|
||||
className="scrollbar-thin overflow-x-hidden overflow-y-auto overscroll-contain pr-2 scroll-smooth"
|
||||
ref={viewportRef}
|
||||
style={rowHeight ? { height: rowHeight } : undefined}
|
||||
onScroll={updateState}
|
||||
>
|
||||
<div className="grid auto-rows-fr grid-cols-2 gap-4 xl:grid-cols-4" ref={trackRef}>
|
||||
{items.map((item, index) => (
|
||||
<div className="min-w-0 [&>*]:h-full" data-rail-card key={index}>{item}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{overflowing && (
|
||||
<div className="mt-2 flex items-center justify-end gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="mr-1">在卡片区域上下滚动查看更多模型大类</span>
|
||||
<Button aria-label="上一行模型大类" disabled={atTop} size="icon-xs" variant="outline" onClick={() => scrollRow(-1)}>
|
||||
<ChevronUp />
|
||||
</Button>
|
||||
<Button aria-label="下一行模型大类" disabled={atBottom} size="icon-xs" variant="outline" onClick={() => scrollRow(1)}>
|
||||
<ChevronDown />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { ArrowRight, Download, FileSpreadsheet, Upload } from "lucide-react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
|
||||
import { usePersistentState } from "~/hooks/use-persistent-state";
|
||||
import { exportRowsToExcel } from "~/lib/exportExcel";
|
||||
import { FilterSelect, OperationsPageHeader, StatusBadge } from "./OperationsUi";
|
||||
import { useOperationsData } from "./OperationsDataContext";
|
||||
import type { ModelRecord } from "./modelData";
|
||||
|
||||
type Filters = {
|
||||
bank: string;
|
||||
name: string;
|
||||
modelId: string;
|
||||
modelType: string;
|
||||
};
|
||||
|
||||
function unique(values: string[]): string[] {
|
||||
return [...new Set(values)].sort((left, right) => left.localeCompare(right, "zh-CN"));
|
||||
}
|
||||
|
||||
function BarList({
|
||||
values,
|
||||
labels,
|
||||
suffix = "%",
|
||||
tone = "brand",
|
||||
}: {
|
||||
values: number[];
|
||||
labels: string[];
|
||||
suffix?: string;
|
||||
tone?: "brand" | "warning" | "success";
|
||||
}) {
|
||||
const max = Math.max(...values, 1);
|
||||
const colorClass = tone === "warning" ? "bg-warning" : tone === "success" ? "bg-success" : "bg-brand";
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{values.length ? values.map((value, index) => (
|
||||
<div className="grid grid-cols-[5rem_minmax(0,1fr)_3.5rem] items-center gap-3" key={labels[index]}>
|
||||
<span className="text-xs text-muted-foreground">{labels[index]}</span>
|
||||
<span className="h-2.5 overflow-hidden rounded-full bg-muted"><i className={`block h-full rounded-full ${colorClass}`} style={{ width: `${value / max * 100}%` }} /></span>
|
||||
<strong className="text-right text-xs tabular-nums text-foreground">{value.toFixed(2)}{suffix}</strong>
|
||||
</div>
|
||||
)) : <div className="rounded-xl border border-dashed border-border p-6 text-center text-xs text-muted-foreground">暂无指标数据</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DeployedModelsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { models } = useOperationsData();
|
||||
const pool = useMemo(() => models.filter((model) => model.status !== "下线"), [models]);
|
||||
const [filters, setFilters] = useState<Filters>({ bank: "", name: "", modelId: "", modelType: "" });
|
||||
const [selectedModelId, setSelectedModelId] = useState(pool[0]?.modelId ?? "");
|
||||
const [scoreFiles, setScoreFiles] = usePersistentState<Record<string, { name: string; size: number; updatedAt: string }>>("a-card-score-files", {});
|
||||
const scoreInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const filtered = pool.filter((model) => (
|
||||
(!filters.bank || model.bank === filters.bank)
|
||||
&& (!filters.name || model.name === filters.name)
|
||||
&& (!filters.modelId || model.modelId === filters.modelId)
|
||||
&& (!filters.modelType || (model.commonModel ? "通用模型" : "个性化模型") === filters.modelType)
|
||||
));
|
||||
const model = filtered.find((item) => item.modelId === selectedModelId) ?? filtered[0] ?? null;
|
||||
const scoreLabels = ["低分段", "中低分", "中分段", "中高分", "高分段", "最高分"];
|
||||
const rankingRates: number[] = [];
|
||||
const liftValues: number[] = [];
|
||||
const psiValues: number[] = [];
|
||||
const scoreFile = model ? scoreFiles[model.modelId] : undefined;
|
||||
|
||||
const update = <K extends keyof Filters>(key: K, value: Filters[K]) => {
|
||||
setFilters((current) => ({ ...current, [key]: value }));
|
||||
setSelectedModelId("");
|
||||
};
|
||||
|
||||
const uploadScoreFile = (file: File | undefined) => {
|
||||
if (!model || !file) return;
|
||||
if (!/\.(xlsx|xls)$/i.test(file.name)) {
|
||||
toast.error("请选择 .xlsx 或 .xls 文件");
|
||||
return;
|
||||
}
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
toast.error("文件不能超过 20 MB");
|
||||
return;
|
||||
}
|
||||
setScoreFiles((current) => ({ ...current, [model.modelId]: { name: file.name, size: file.size, updatedAt: new Date().toLocaleString("zh-CN", { hour12: false }) } }));
|
||||
toast.success("评分逻辑文件已加入本地上传队列");
|
||||
};
|
||||
|
||||
const downloadScoreFile = () => {
|
||||
if (!model || !scoreFile) return;
|
||||
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="查看已上线模型的生命周期、开发时点指标、评分逻辑和开发材料链接。"
|
||||
actions={model && <Button variant="outline" onClick={() => navigate(`/operations/monitoring/${model.modelId}`)}>查看监控详情 <ArrowRight /></Button>}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="border-b border-border">
|
||||
<CardTitle>模型筛选</CardTitle>
|
||||
<CardDescription>筛出 {filtered.length} 个模型</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<FilterSelect label="银行" value={filters.bank} options={unique(pool.map((item) => item.bank))} onChange={(value) => update("bank", value)} />
|
||||
<FilterSelect label="模型名称" value={filters.name} options={unique(pool.map((item) => item.name))} onChange={(value) => update("name", value)} />
|
||||
<FilterSelect label="模型 ID" value={filters.modelId} options={pool.map((item) => item.modelId)} onChange={(value) => update("modelId", value)} />
|
||||
<FilterSelect label="模型类型" value={filters.modelType} options={["通用模型", "个性化模型"]} onChange={(value) => update("modelType", value)} />
|
||||
</div>
|
||||
<FilterSelect
|
||||
label="查看模型"
|
||||
value={model?.modelId ?? ""}
|
||||
allLabel={filtered.length ? "请选择模型" : "无匹配模型"}
|
||||
options={filtered.map((item) => ({ label: `${item.bank} · ${item.name} ${item.version}(${item.modelId})`, value: item.modelId }))}
|
||||
onChange={setSelectedModelId}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{model ? (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="border-b border-border">
|
||||
<CardTitle>{model.bank} · {model.name} {model.version}</CardTitle>
|
||||
<CardDescription className="font-mono">{model.modelId}</CardDescription>
|
||||
<CardAction className="flex items-center gap-2"><span className={model.commonModel ? "rounded-full bg-brand-soft px-2.5 py-1 text-xs font-medium text-primary" : "rounded-full bg-muted px-2.5 py-1 text-xs font-medium text-muted-foreground"}>{model.commonModel ? `通用 · ${model.commonModel}` : "个性化模型"}</span><StatusBadge status={model.status} /></CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-9 gap-3">
|
||||
{[
|
||||
["银行 × 模型ID", `${model.bank} × ${model.modelId}`],
|
||||
["版本", model.version],
|
||||
["开发人员", "—"],
|
||||
["上线日期", "—"],
|
||||
["最近迭代", model.iteratedAt],
|
||||
["陪跑开始", "—"],
|
||||
["陪跑结束", "—"],
|
||||
["下线日期", "—"],
|
||||
["状态", model.status],
|
||||
].map(([label, value]) => (
|
||||
<div className="min-w-0 rounded-xl bg-muted/50 p-3" key={label}><span className="block whitespace-nowrap text-2xs text-muted-foreground">{label}</span><strong className="mt-1 block truncate text-sm font-semibold tabular-nums text-foreground" title={value}>{value}</strong></div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<Card size="sm">
|
||||
<CardHeader><CardTitle>开发时点 · 排序性</CardTitle><CardDescription>各评分区间坏客户占比</CardDescription></CardHeader>
|
||||
<CardContent><BarList values={rankingRates} labels={scoreLabels} tone="warning" /><p className="mt-4 rounded-xl bg-success-soft p-3 text-xs text-success-strong">开发时点排序性相符:评分越高,坏客户占比越低。</p></CardContent>
|
||||
</Card>
|
||||
<Card size="sm">
|
||||
<CardHeader><CardTitle>开发时点 · KS 与 LIFT</CardTitle><CardDescription>暂无开发时点指标</CardDescription></CardHeader>
|
||||
<CardContent><BarList values={liftValues} labels={scoreLabels} suffix="" tone="brand" /></CardContent>
|
||||
</Card>
|
||||
<Card size="sm">
|
||||
<CardHeader><CardTitle>开发时点 · PSI</CardTitle><CardDescription>暂无开发时点指标</CardDescription></CardHeader>
|
||||
<CardContent><BarList values={psiValues} labels={scoreLabels} tone="success" /><p className="mt-4 text-xs text-muted-foreground">上线后的滚动 PSI 请进入模型监控详情查看。</p></CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>评分逻辑</CardTitle><CardDescription>以 Excel 文件维护,不在页面逐行展开</CardDescription></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-3 rounded-xl border border-border p-4">
|
||||
<span className="grid size-10 place-items-center rounded-xl bg-success-soft text-success-strong"><FileSpreadsheet className="size-5" /></span>
|
||||
<span className="min-w-0 flex-1"><b className="block truncate text-sm text-foreground">{scoreFile?.name ?? "暂无评分逻辑文件"}</b><small className="text-xs text-muted-foreground">{scoreFile ? `本地待上传 · ${(scoreFile.size / 1024).toFixed(1)} KB · ${scoreFile.updatedAt}` : "未关联评分逻辑文件"}</small></span>
|
||||
<Button variant="outline" size="sm" onClick={downloadScoreFile}><Download />下载</Button>
|
||||
</div>
|
||||
<input ref={scoreInputRef} className="hidden" type="file" accept=".xlsx,.xls" onChange={(event) => { uploadScoreFile(event.target.files?.[0]); event.target.value = ""; }} />
|
||||
<Button className="w-full" variant="outline" onClick={() => scoreInputRef.current?.click()}><Upload />上传或替换评分逻辑 Excel</Button>
|
||||
<p className="text-xs leading-5 text-muted-foreground">增量模型由开发评审材料自动带入;存量模型由模型团队手工上传。</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>模型开发材料</CardTitle><CardDescription>材料存放于开发评审域,本页仅提供链接</CardDescription></CardHeader>
|
||||
<CardContent className="px-0">
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>材料</TableHead><TableHead>环节</TableHead><TableHead className="text-right">操作</TableHead></TableRow></TableHeader>
|
||||
<TableBody><TableRow><TableCell colSpan={3} className="h-28 text-center text-muted-foreground">暂无模型开发材料</TableCell></TableRow></TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Card><CardContent className="py-16 text-center text-sm text-muted-foreground">当前筛选条件下没有匹配的已上线模型</CardContent></Card>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Download, FolderArchive, RotateCcw } from "lucide-react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
|
||||
import { exportRowsToExcel } from "~/lib/exportExcel";
|
||||
import { FilterSelect, OperationsPageHeader } from "./OperationsUi";
|
||||
import { workflowDocuments, type KnowledgeDocument } from "./workflowData";
|
||||
|
||||
type Filters = { bank: string; modelName: string; modelId: string; stage: string };
|
||||
const EMPTY_FILTERS: Filters = { bank: "", modelName: "", modelId: "", stage: "" };
|
||||
|
||||
function unique(values: string[]): string[] {
|
||||
return [...new Set(values)].sort((left, right) => left.localeCompare(right, "zh-CN"));
|
||||
}
|
||||
|
||||
function exportDocuments(rows: KnowledgeDocument[]) {
|
||||
const header = ["流程", "银行", "模型名称", "模型版本", "模型ID", "环节", "材料名称", "上传人", "上传时间", "确认状态"];
|
||||
return exportRowsToExcel({ fileName: "文档知识库", sheetName: "文档知识库", headers: header, rows: rows.map((row) => [row.workflowTitle, row.bank, row.modelName, row.modelVersion, row.modelId, row.stage, row.name, row.uploadedBy, row.uploadedAt, row.confirmed ? "已确认" : "待确认"]) });
|
||||
}
|
||||
|
||||
export default function KnowledgeBasePage() {
|
||||
const documents = useMemo(() => workflowDocuments(), []);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [filters, setFilters] = useState<Filters>(() => ({
|
||||
...EMPTY_FILTERS,
|
||||
bank: searchParams.get("bank") ?? "",
|
||||
modelName: searchParams.get("modelName") ?? "",
|
||||
modelId: searchParams.get("modelId") ?? "",
|
||||
stage: searchParams.get("stage") ?? "",
|
||||
}));
|
||||
const rows = useMemo(() => documents.filter((document) => (
|
||||
(!filters.bank || document.bank === filters.bank)
|
||||
&& (!filters.modelName || document.modelName === filters.modelName)
|
||||
&& (!filters.modelId || document.modelId === filters.modelId)
|
||||
&& (!filters.stage || document.stage === filters.stage)
|
||||
)), [documents, filters]);
|
||||
const update = <K extends keyof Filters>(key: K, value: Filters[K]) => setFilters((current) => ({ ...current, [key]: value }));
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
(Object.keys(filters) as Array<keyof Filters>).forEach((key) => { if (filters[key]) params.set(key, filters[key]); });
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [filters, setSearchParams]);
|
||||
|
||||
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="汇总开发、迭代、评审、测试和部署各环节材料,按模型、名称和环节检索。"
|
||||
actions={<Button onClick={() => void exportDocuments(rows)}><Download />导出 Excel</Button>}
|
||||
/>
|
||||
<Card>
|
||||
<CardHeader className="border-b border-border"><CardTitle className="flex items-center gap-2"><FolderArchive className="size-5 text-primary" />流程材料</CardTitle><CardDescription>共 {rows.length} 份材料</CardDescription><CardAction><Button variant="outline" size="sm" onClick={() => setFilters(EMPTY_FILTERS)}><RotateCcw />重置筛选</Button></CardAction></CardHeader>
|
||||
<CardContent className="grid grid-cols-4 gap-3">
|
||||
<FilterSelect label="银行" value={filters.bank} options={unique(documents.map((item) => item.bank))} onChange={(value) => update("bank", value)} />
|
||||
<FilterSelect label="模型名称" value={filters.modelName} options={unique(documents.map((item) => item.modelName))} onChange={(value) => update("modelName", value)} />
|
||||
<FilterSelect label="模型 ID" value={filters.modelId} options={unique(documents.map((item) => item.modelId))} onChange={(value) => update("modelId", value)} />
|
||||
<FilterSelect label="环节" value={filters.stage} options={unique(documents.map((item) => item.stage))} onChange={(value) => update("stage", value)} />
|
||||
</CardContent>
|
||||
<CardContent className="px-0 pt-0">
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>流程</TableHead><TableHead>银行</TableHead><TableHead>模型名称</TableHead><TableHead>模型版本</TableHead><TableHead>模型ID</TableHead><TableHead>环节</TableHead><TableHead>材料名称</TableHead><TableHead>上传人</TableHead><TableHead>上传时间</TableHead><TableHead>确认状态</TableHead><TableHead className="text-right">操作</TableHead></TableRow></TableHeader>
|
||||
<TableBody>{rows.length ? rows.map((document) => <TableRow key={`${document.workflowId}-${document.stage}-${document.name}`}><TableCell className="min-w-48 whitespace-normal font-medium text-foreground">{document.workflowTitle}</TableCell><TableCell>{document.bank}</TableCell><TableCell>{document.modelName}</TableCell><TableCell>{document.modelVersion}{document.commonModel && <span className="ml-2 rounded-full bg-brand-soft px-2 py-0.5 text-2xs text-primary">通用</span>}</TableCell><TableCell className="font-mono text-xs">{document.modelId}</TableCell><TableCell>{document.stage}</TableCell><TableCell className="min-w-56 whitespace-normal font-mono text-xs">{document.name}</TableCell><TableCell>{document.uploadedBy}</TableCell><TableCell>{document.uploadedAt}</TableCell><TableCell>{document.confirmed ? <span className="rounded-full bg-success-soft px-2.5 py-1 text-xs text-success-strong">已确认</span> : <span className="rounded-full bg-warning-soft px-2.5 py-1 text-xs text-warning">待确认</span>}</TableCell><TableCell className="text-right"><Button variant="link" size="sm" onClick={() => toast.info("文件下载待文件服务接入")}>下载</Button></TableCell></TableRow>) : <TableRow><TableCell colSpan={11} className="h-32 text-center text-muted-foreground">当前筛选条件下暂无材料</TableCell></TableRow>}</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ArrowRight, CheckCircle2, Clock3, FileText, Info, RefreshCw, TriangleAlert } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
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 { Skeleton } from "~/components/ui/skeleton";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import {
|
||||
AbnormalBadge,
|
||||
FilterSelect,
|
||||
GradeBadge,
|
||||
MetricLineChart,
|
||||
OperationsPageHeader,
|
||||
SortingComboChart,
|
||||
StatusBadge,
|
||||
} from "./OperationsUi";
|
||||
import {
|
||||
FEATURE_METRICS,
|
||||
SORTING_DISTRIBUTION,
|
||||
abnormalLevelOf,
|
||||
abnormalReasonOf,
|
||||
gradeOf,
|
||||
modelTrend,
|
||||
monthsBetween,
|
||||
} from "./modelData";
|
||||
import { useOperationsData, useOperationsModelDetail } from "./OperationsDataContext";
|
||||
import { useCanOperationsAction } from "./operationsRole";
|
||||
|
||||
function recentMonths(count: number): string[] {
|
||||
const end = 2026 * 12 + 6;
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const value = end - count + 1 + index;
|
||||
return `${Math.floor(value / 12)}-${String(value % 12 + 1).padStart(2, "0")}`;
|
||||
});
|
||||
}
|
||||
|
||||
export default function MonitoringDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const params = useParams();
|
||||
const { models } = useOperationsData();
|
||||
const modelId = params.modelId ?? models[0]?.modelId ?? "";
|
||||
const detail = useOperationsModelDetail(modelId, "2026-07");
|
||||
const baseModel = detail.model ?? models.find((item) => item.modelId === modelId) ?? models[0];
|
||||
const model = detail.monitoringResult ?? baseModel ?? models[0]!;
|
||||
const [range, setRange] = useState("6");
|
||||
const [fromMonth, setFromMonth] = useState("2026-02");
|
||||
const [toMonth, setToMonth] = useState("2026-07");
|
||||
const [compareId, setCompareId] = useState("");
|
||||
const [selectedFeature, setSelectedFeature] = useState<string | null>(null);
|
||||
const [reviewStage, setReviewStage] = useState<0 | 1 | 2>(0);
|
||||
const [reviewDecision, setReviewDecision] = useState("暂不处理");
|
||||
const [reviewNote, setReviewNote] = useState("");
|
||||
const months = range === "custom" ? monthsBetween(fromMonth, toMonth) : recentMonths(Number(range));
|
||||
const monitorMonth = detail.monitoringResult?.monitorMonth ?? "";
|
||||
const ksTrend = modelTrend(model, "ks", months);
|
||||
const psiTrend = modelTrend(model, "psi", months);
|
||||
const compareModel = models.find((item) => item.modelId === compareId && item.modelId !== model.modelId) ?? null;
|
||||
const compareKsTrend = compareModel ? modelTrend(compareModel, "ks", months) : null;
|
||||
const comparePsiTrend = compareModel ? modelTrend(compareModel, "psi", months) : null;
|
||||
const grade = gradeOf(model);
|
||||
const abnormal = abnormalLevelOf(model);
|
||||
const chosenFeature = FEATURE_METRICS.find((item) => item.key === selectedFeature) ?? null;
|
||||
const ivTop = useMemo(() => [...FEATURE_METRICS].sort((left, right) => right.ivDrop - left.ivDrop), []);
|
||||
const csiTop = useMemo(() => [...FEATURE_METRICS].sort((left, right) => right.csiRise - left.csiRise), []);
|
||||
const canInitialReview = useCanOperationsAction("monitor:initial-review");
|
||||
const canFinalReview = useCanOperationsAction("monitor:final-review");
|
||||
const effectiveReviewStage = grade === "A" ? 2 : reviewStage;
|
||||
const canReview = grade !== "A" && ((reviewStage === 0 && canInitialReview) || (reviewStage === 1 && canFinalReview));
|
||||
|
||||
const submitReview = () => {
|
||||
if (!reviewNote.trim()) {
|
||||
toast.error("请填写处理说明");
|
||||
return;
|
||||
}
|
||||
if (reviewStage === 0) {
|
||||
setReviewStage(1);
|
||||
toast.success(`模型团队初审已提交:${reviewDecision}`);
|
||||
} else {
|
||||
setReviewStage(2);
|
||||
toast.success(`业务团队终审已提交:${reviewDecision}`);
|
||||
}
|
||||
setReviewNote("");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setReviewStage(0);
|
||||
setReviewDecision("暂不处理");
|
||||
setReviewNote("");
|
||||
setSelectedFeature(null);
|
||||
setCompareId("");
|
||||
}, [modelId]);
|
||||
|
||||
if (detail.loading) {
|
||||
return <section className="h-full overflow-auto bg-bg p-6"><div className="w-full space-y-6"><Skeleton className="h-20 w-full rounded-4xl" /><Skeleton className="h-96 w-full rounded-4xl" /><div className="grid grid-cols-2 gap-6"><Skeleton className="h-72 rounded-4xl" /><Skeleton className="h-72 rounded-4xl" /></div></div></section>;
|
||||
}
|
||||
|
||||
if (detail.error) {
|
||||
return <section className="grid h-full place-items-center bg-bg p-6"><Card className="w-full max-w-xl"><CardContent className="flex flex-col items-center py-12 text-center"><TriangleAlert className="size-10 text-danger" /><h2 className="mt-4 text-xl font-bold text-foreground">模型监控详情加载失败</h2><p className="mt-2 text-sm text-muted-foreground">{detail.error ?? "模型不存在"}</p><Button className="mt-5" onClick={detail.reload}><RefreshCw />重新加载</Button></CardContent></Card></section>;
|
||||
}
|
||||
|
||||
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={`${model.bank} · ${model.name} ${model.version} · ${model.modelId}`}
|
||||
onBack={() => navigate("/operations/monitoring")}
|
||||
actions={(
|
||||
<div className="flex items-end gap-2">
|
||||
<FilterSelect
|
||||
className="w-72"
|
||||
label="监控模型"
|
||||
value={model.modelId}
|
||||
allLabel="请选择模型"
|
||||
options={models.filter((item) => item.status !== "下线").map((item) => ({
|
||||
label: `${item.bank} · ${item.name} ${item.version}`,
|
||||
value: item.modelId,
|
||||
}))}
|
||||
onChange={(modelId) => {
|
||||
if (modelId) navigate(`/operations/monitoring/${modelId}`);
|
||||
}}
|
||||
/>
|
||||
<FilterSelect
|
||||
className="w-40"
|
||||
label="时间范围"
|
||||
value={range}
|
||||
allLabel="请选择"
|
||||
options={[{ label: "近 3 个月", value: "3" }, { label: "近 6 个月", value: "6" }, { label: "近 12 个月", value: "12" }, { label: "自定义", value: "custom" }]}
|
||||
onChange={setRange}
|
||||
/>
|
||||
<FilterSelect
|
||||
className="w-64"
|
||||
label="对比模型"
|
||||
value={compareId}
|
||||
allLabel="不对比"
|
||||
options={models.filter((item) => item.status !== "下线" && item.modelId !== model.modelId).map((item) => ({ label: `${item.bank} · ${item.modelId}`, value: item.modelId }))}
|
||||
onChange={setCompareId}
|
||||
/>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="border-b border-border">
|
||||
<CardTitle>① 监控结论</CardTitle>
|
||||
<CardDescription>{monitorMonth ? `${monitorMonth} 监控周期 · 结果优先展示` : "暂无监控结果"}</CardDescription>
|
||||
<CardAction className="flex items-center gap-2">
|
||||
<StatusBadge status={model.status} />
|
||||
<GradeBadge grade={grade} suffix="等级" />
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="grid grid-cols-6 gap-4">
|
||||
{[
|
||||
["监控结果等级", grade],
|
||||
["排序性", model.ranking],
|
||||
["KS", `${model.ks.toFixed(2)}%`],
|
||||
["PSI", `${model.psi.toFixed(2)}%`],
|
||||
["KS 环比降幅", `${model.ksDrop.toFixed(2)}%`],
|
||||
["异常等级", abnormal],
|
||||
].map(([label, value]) => (
|
||||
<div className="rounded-xl bg-muted/50 p-4" key={label}>
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<strong className="mt-2 block whitespace-nowrap text-xl font-bold tabular-nums text-foreground">{value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{grade === "A" ? (
|
||||
<div className="flex items-start gap-3 rounded-xl bg-success-soft p-4 text-sm text-success-strong">
|
||||
<CheckCircle2 className="mt-0.5 size-5 shrink-0" />
|
||||
<div><b>本期无异常</b><p className="mt-1">各项指标均在阈值内,生成模型监控报告并继续按月监控。</p></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-3 rounded-xl bg-warning-soft p-4 text-sm text-foreground">
|
||||
<TriangleAlert className="mt-0.5 size-5 shrink-0 text-warning" />
|
||||
<div>
|
||||
<b>异常原因({abnormal})</b>
|
||||
<p className="mt-1 text-muted-foreground">{abnormalReasonOf(model)}</p>
|
||||
{(model.ks < 30 || model.psi > 50) && <p className="mt-2 font-medium text-danger">已达到主动干预条件,请建模团队优先处理。</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-start gap-3 rounded-xl border border-border p-4">
|
||||
<Clock3 className="mt-0.5 size-5 shrink-0 text-primary" />
|
||||
<div className="flex-1">
|
||||
<b className="text-sm text-foreground">处理进度</b>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{grade === "A" ? "本期无异常,无需进入处理流程。" : effectiveReviewStage === 0 ? "待模型团队初审,初审完成后流转至业务团队终审。" : effectiveReviewStage === 1 ? "模型团队已完成初审,待业务团队终审。" : "本期处理已结案。"}</p>
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className={`rounded-full px-2.5 py-1 ${effectiveReviewStage >= 1 ? "bg-success text-white" : "bg-primary text-white"}`}>1 模型团队初审</span>
|
||||
<ArrowRight className="size-3.5" />
|
||||
<span className={`rounded-full px-2.5 py-1 ${effectiveReviewStage >= 2 ? "bg-success text-white" : effectiveReviewStage === 1 ? "bg-primary text-white" : "bg-muted"}`}>2 业务团队终审</span>
|
||||
</div>
|
||||
{canReview && <div className="mt-4 space-y-3 border-t border-border pt-4"><FilterSelect label={reviewStage === 0 ? "模型团队处理建议" : "业务团队终审结论"} value={reviewDecision} allLabel="请选择" options={["暂不处理", "模型微调或重构"]} onChange={setReviewDecision} /><Textarea placeholder="必填:请输入本次处理依据或结论" value={reviewNote} onChange={(event) => setReviewNote(event.target.value)} /><Button size="sm" onClick={submitReview}>提交{reviewStage === 0 ? "初审" : "终审"}</Button></div>}
|
||||
{!canReview && grade !== "A" && effectiveReviewStage < 2 && <p className="mt-3 text-xs text-muted-foreground">{effectiveReviewStage === 0 ? "当前角色需等待模型团队完成初审。" : "当前角色需等待业务团队完成终审。"}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 rounded-xl border border-border p-4">
|
||||
<FileText className="mt-0.5 size-5 shrink-0 text-primary" />
|
||||
<div className="flex-1">
|
||||
<b className="text-sm text-foreground">关联报告</b>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{monitorMonth ? `${grade === "A" ? "模型监控报告" : "模型诊断报告"} · ${monitorMonth}` : "暂无关联报告"}</p>
|
||||
<Button className="mt-2 px-0" variant="link" size="sm" onClick={() => navigate("/operations/reports")}>打开报告 <ArrowRight /></Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="border-b border-border">
|
||||
<CardTitle>② 监控指标趋势</CardTitle>
|
||||
<CardDescription>{months[0]} 至 {months[months.length - 1]} · PSI 使用滚动基准期{compareModel ? ` · 对比 ${compareModel.modelId}` : ""}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<Card size="sm">
|
||||
<CardHeader><CardTitle>KS 趋势</CardTitle><CardDescription>分档线 40%,干预线 30%</CardDescription></CardHeader>
|
||||
<CardContent><MetricLineChart months={months} values={ksTrend} name={`${model.bank} ${model.modelId} · KS`} comparison={compareModel && compareKsTrend ? { name: `${compareModel.bank} ${compareModel.modelId}`, values: compareKsTrend } : undefined} thresholds={[{ value: 40, label: "40% 分档线", tone: "danger" }, { value: 30, label: "30% 干预线", tone: "warning" }]} /></CardContent>
|
||||
</Card>
|
||||
<Card size="sm">
|
||||
<CardHeader><CardTitle>PSI 趋势(滚动基准期)</CardTitle><CardDescription>10% 关注线,25% 偏移线</CardDescription></CardHeader>
|
||||
<CardContent><MetricLineChart months={months} values={psiTrend} name={`${model.bank} ${model.modelId} · PSI`} comparison={compareModel && comparePsiTrend ? { name: `${compareModel.bank} ${compareModel.modelId}`, values: comparePsiTrend } : undefined} thresholds={[{ value: 10, label: "10% 关注线", tone: "warning" }, { value: 25, label: "25% 偏移线", tone: "danger" }]} /></CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card size="sm">
|
||||
<CardHeader><CardTitle>排序性趋势(当月)</CardTitle><CardDescription>{monitorMonth || "当期"} · 蓝色柱为客户数,橙色折线为坏客户占比</CardDescription></CardHeader>
|
||||
<CardContent><SortingComboChart {...SORTING_DISTRIBUTION} /></CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<Card size="sm">
|
||||
<CardHeader><CardTitle>IV · 降幅前 5 特征</CardTitle><CardDescription>只展示降幅最大的五项</CardDescription></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{ivTop.map((feature) => (
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3" key={feature.key}>
|
||||
<span><b className="block text-sm text-foreground">{feature.name}</b><small className="font-mono text-xs text-muted-foreground">{feature.key}</small></span>
|
||||
<span className="text-right"><b className="block tabular-nums text-danger">-{feature.ivDrop}%</b><small className="text-xs text-muted-foreground">IV {feature.iv.toFixed(3)}</small></span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card size="sm">
|
||||
<CardHeader><CardTitle>CSI · 升幅前 5 特征</CardTitle><CardDescription>只展示升幅最大的五项</CardDescription></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{csiTop.map((feature) => (
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3" key={feature.key}>
|
||||
<span><b className="block text-sm text-foreground">{feature.name}</b><small className="font-mono text-xs text-muted-foreground">{feature.key}</small></span>
|
||||
<span className="text-right"><b className="block tabular-nums text-warning">+{feature.csiRise}pp</b><small className="text-xs text-muted-foreground">CSI {feature.csi.toFixed(3)}</small></span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="border-b border-border">
|
||||
<CardTitle>③ 特征分析</CardTitle>
|
||||
<CardDescription>先查看全量指标概览,再进入单个特征查看分布变化</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>入模特征</TableHead>
|
||||
<TableHead>特征释义</TableHead>
|
||||
<TableHead>IV(当期)</TableHead>
|
||||
<TableHead>IV 降幅</TableHead>
|
||||
<TableHead>CSI(当期)</TableHead>
|
||||
<TableHead>CSI 升幅</TableHead>
|
||||
<TableHead>KS 贡献变动</TableHead>
|
||||
<TableHead>PSI 贡献变动</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{FEATURE_METRICS.map((feature) => (
|
||||
<TableRow data-state={selectedFeature === feature.key ? "selected" : undefined} key={feature.key}>
|
||||
<TableCell className="font-mono text-xs">{feature.key}</TableCell>
|
||||
<TableCell className="font-medium text-foreground">{feature.name}</TableCell>
|
||||
<TableCell className="tabular-nums">{feature.iv.toFixed(3)}</TableCell>
|
||||
<TableCell className="tabular-nums text-danger">-{feature.ivDrop}%</TableCell>
|
||||
<TableCell className="tabular-nums">{feature.csi.toFixed(3)}</TableCell>
|
||||
<TableCell className="tabular-nums text-warning">+{feature.csiRise}pp</TableCell>
|
||||
<TableCell className="tabular-nums">{feature.ksContribution > 0 ? "+" : ""}{feature.ksContribution}pp</TableCell>
|
||||
<TableCell className="tabular-nums">{feature.psiContribution > 0 ? "+" : ""}{feature.psiContribution}pp</TableCell>
|
||||
<TableCell className="text-right"><Button variant="link" size="sm" onClick={() => setSelectedFeature(feature.key)}>查看分布</Button></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
|
||||
{chosenFeature && (
|
||||
<CardContent className="border-t border-border">
|
||||
<div className="mb-4 flex items-start gap-3 rounded-xl bg-brand-soft p-4 text-sm text-primary">
|
||||
<Info className="mt-0.5 size-5 shrink-0" />
|
||||
<div><b>{chosenFeature.name}({chosenFeature.key})分布变化</b><p className="mt-1 text-primary/80">对比基准期与当期各分箱占比,正式数据由后端接口读取。</p></div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dashed border-border p-8 text-center text-sm text-muted-foreground">暂无特征分布数据</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ArrowUpDown, ChevronLeft, ChevronRight, Download, RotateCcw } 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
|
||||
import { exportRowsToExcel } from "~/lib/exportExcel";
|
||||
import {
|
||||
AbnormalBadge,
|
||||
FilterSelect,
|
||||
GradeBadge,
|
||||
MultiSelectFilter,
|
||||
OperationsPageHeader,
|
||||
StatusBadge,
|
||||
} from "./OperationsUi";
|
||||
import {
|
||||
MONITOR_MONTHS,
|
||||
abnormalLevelOf,
|
||||
abnormalReasonOf,
|
||||
categoryName,
|
||||
gradeOf,
|
||||
monitoringRows,
|
||||
type ModelGrade,
|
||||
type MonitoringRow,
|
||||
} from "./modelData";
|
||||
import { useOperationsData } from "./OperationsDataContext";
|
||||
|
||||
type Filters = {
|
||||
bank: string[];
|
||||
wuji: string[];
|
||||
name: string[];
|
||||
modelId: string[];
|
||||
month: string[];
|
||||
status: string[];
|
||||
abnormal: string[];
|
||||
grade: string[];
|
||||
ranking: string[];
|
||||
ksBand: string[];
|
||||
psiBand: string[];
|
||||
dropBand: string[];
|
||||
};
|
||||
|
||||
type ArrayFilterKey = keyof Filters;
|
||||
type SortKey = "bank" | "modelId" | "month" | "ks" | "psi" | "grade";
|
||||
type SortState = { key: SortKey; direction: "asc" | "desc" };
|
||||
|
||||
function defaultFilters(): Filters {
|
||||
return {
|
||||
bank: [],
|
||||
wuji: [],
|
||||
name: [],
|
||||
modelId: [],
|
||||
month: ["2026-07"],
|
||||
status: [],
|
||||
abnormal: [],
|
||||
grade: ["B", "C"],
|
||||
ranking: [],
|
||||
ksBand: [],
|
||||
psiBand: [],
|
||||
dropBand: [],
|
||||
};
|
||||
}
|
||||
|
||||
function unique(values: string[]): string[] {
|
||||
return [...new Set(values)].sort((left, right) => left.localeCompare(right, "zh-CN"));
|
||||
}
|
||||
|
||||
function parseValues(params: URLSearchParams, key: string, fallback: string[] = []): string[] {
|
||||
const values = params.getAll(key).flatMap((value) => value.split(",")).filter(Boolean);
|
||||
return values.length ? [...new Set(values)] : fallback;
|
||||
}
|
||||
|
||||
function matches(values: string[], value: string): boolean {
|
||||
return !values.length || values.includes(value);
|
||||
}
|
||||
|
||||
function exportMonitoringExcel(rows: MonitoringRow[]) {
|
||||
const headers = [
|
||||
"银行", "是否无极银行", "模型名称", "模型版本", "模型ID", "月份", "模型状态", "排序性",
|
||||
"KS", "PSI", "KS环比降幅", "模型异常等级", "模型监控结果等级", "异常原因",
|
||||
];
|
||||
const dataRows = rows.map((row) => [
|
||||
row.bank, row.wuji ? "是" : "否", row.name, row.version, row.modelId, row.monitorMonth,
|
||||
row.status, row.ranking, `${row.ks.toFixed(2)}%`, `${row.psi.toFixed(2)}%`, `${row.ksDrop.toFixed(2)}%`,
|
||||
abnormalLevelOf(row), gradeOf(row), abnormalReasonOf(row),
|
||||
]);
|
||||
return exportRowsToExcel({ fileName: "模型监控明细", sheetName: "监控明细", headers, rows: dataRows });
|
||||
}
|
||||
|
||||
function SortableHead({ label, sortKey, sort, onSort }: { label: string; sortKey: SortKey; sort: SortState; onSort: (key: SortKey) => void }) {
|
||||
return <TableHead><button className="inline-flex items-center gap-1.5 font-medium hover:text-primary" type="button" onClick={() => onSort(sortKey)}>{label}<ArrowUpDown className={`size-3.5 ${sort.key === sortKey ? "text-primary" : "text-muted-foreground"}`} /></button></TableHead>;
|
||||
}
|
||||
|
||||
export default function MonitoringOverviewPage() {
|
||||
const navigate = useNavigate();
|
||||
const { models } = useOperationsData();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const initial = defaultFilters();
|
||||
const [filters, setFilters] = useState<Filters>(() => ({
|
||||
bank: parseValues(searchParams, "bank"),
|
||||
wuji: parseValues(searchParams, "wuji"),
|
||||
name: parseValues(searchParams, "name"),
|
||||
modelId: parseValues(searchParams, "modelId"),
|
||||
month: parseValues(searchParams, "month", initial.month),
|
||||
status: parseValues(searchParams, "status"),
|
||||
abnormal: parseValues(searchParams, "abnormal"),
|
||||
grade: parseValues(searchParams, "grade", initial.grade),
|
||||
ranking: parseValues(searchParams, "ranking"),
|
||||
ksBand: parseValues(searchParams, "ksBand"),
|
||||
psiBand: parseValues(searchParams, "psiBand"),
|
||||
dropBand: parseValues(searchParams, "dropBand"),
|
||||
}));
|
||||
const [sort, setSort] = useState<SortState>({ key: "month", direction: "desc" });
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const rows = useMemo(() => monitoringRows(models), [models]);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
(Object.keys(filters) as Array<keyof Filters>).forEach((key) => {
|
||||
filters[key].forEach((value) => params.append(key, value));
|
||||
});
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [filters, setSearchParams]);
|
||||
|
||||
const filteredRows = useMemo(() => rows.filter((row) => {
|
||||
if (!matches(filters.bank, row.bank)) return false;
|
||||
if (!matches(filters.wuji, row.wuji ? "是" : "否")) return false;
|
||||
if (!matches(filters.name, row.name)) return false;
|
||||
if (!matches(filters.modelId, row.modelId)) return false;
|
||||
if (!matches(filters.month, row.monitorMonth)) return false;
|
||||
if (!matches(filters.status, row.status)) return false;
|
||||
if (!matches(filters.abnormal, abnormalLevelOf(row))) return false;
|
||||
if (!matches(filters.grade, gradeOf(row))) return false;
|
||||
if (!matches(filters.ranking, row.ranking)) return false;
|
||||
if (!matches(filters.ksBand, row.ks >= 40 ? ">=40%" : "<40%")) return false;
|
||||
const psiBand = row.psi <= 10 ? "<=10%" : row.psi <= 25 ? "10%-25%" : ">25%";
|
||||
if (!matches(filters.psiBand, psiBand)) return false;
|
||||
if (!matches(filters.dropBand, row.ksDrop > 20 ? ">20%" : "<=20%")) return false;
|
||||
return true;
|
||||
}), [filters, rows]);
|
||||
|
||||
const sortedRows = useMemo(() => [...filteredRows].sort((left, right) => {
|
||||
const values: Record<SortKey, [string | number, string | number]> = {
|
||||
bank: [left.bank, right.bank],
|
||||
modelId: [left.modelId, right.modelId],
|
||||
month: [left.monitorMonth, right.monitorMonth],
|
||||
ks: [left.ks, right.ks],
|
||||
psi: [left.psi, right.psi],
|
||||
grade: [gradeOf(left), gradeOf(right)],
|
||||
};
|
||||
const [a, b] = values[sort.key];
|
||||
const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), "zh-CN");
|
||||
return sort.direction === "asc" ? result : -result;
|
||||
}), [filteredRows, sort]);
|
||||
|
||||
useEffect(() => setPage(1), [filters, pageSize, sort]);
|
||||
const pageCount = Math.max(1, Math.ceil(sortedRows.length / pageSize));
|
||||
const safePage = Math.min(page, pageCount);
|
||||
const pageRows = sortedRows.slice((safePage - 1) * pageSize, safePage * pageSize);
|
||||
const rangeStart = sortedRows.length ? (safePage - 1) * pageSize + 1 : 0;
|
||||
const rangeEnd = Math.min(safePage * pageSize, sortedRows.length);
|
||||
|
||||
const latestModels = models.filter((model) => model.status !== "下线");
|
||||
const gradeGroups = (["A", "B", "C"] as ModelGrade[]).map((grade) => {
|
||||
const groupModels = latestModels.filter((model) => gradeOf(model) === grade);
|
||||
return {
|
||||
grade,
|
||||
models: groupModels,
|
||||
banks: new Set(groupModels.map((model) => model.bank)).size,
|
||||
title: grade === "A" ? "运行正常" : grade === "B" ? "需要关注" : "需要处理",
|
||||
description: grade === "A" ? "生成监控报告" : grade === "B" ? "生成诊断报告" : "诊断报告并评估模型",
|
||||
};
|
||||
});
|
||||
|
||||
const update = <K extends ArrayFilterKey>(key: K, value: string[]) => setFilters((current) => ({ ...current, [key]: value }));
|
||||
const changeSort = (key: SortKey) => setSort((current) => current.key === key ? { key, direction: current.direction === "asc" ? "desc" : "asc" } : { key, direction: "asc" });
|
||||
|
||||
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="查看监控结果等级分布与按月展开的监控明细,默认展示最新月份的 B / C 等级模型。" actions={<Button variant="outline" onClick={() => void exportMonitoringExcel(sortedRows)}><Download />导出 Excel</Button>} />
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{gradeGroups.map((group) => (
|
||||
<Card key={group.grade} size="sm">
|
||||
<CardHeader><CardTitle className="flex items-center gap-2"><GradeBadge grade={group.grade} />{group.title}</CardTitle><CardDescription>{group.description}</CardDescription><CardAction><Button variant="ghost" size="sm" onClick={() => setFilters((current) => ({ ...current, grade: [group.grade], month: ["2026-07"] }))}>筛选该等级</Button></CardAction></CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-4"><div className="rounded-xl bg-muted/50 p-3"><span className="text-xs text-muted-foreground">银行数</span><strong className="mt-1 block text-xl tabular-nums">{group.banks}</strong></div><div className="rounded-xl bg-muted/50 p-3"><span className="text-xs text-muted-foreground">模型数</span><strong className="mt-1 block text-xl tabular-nums">{group.models.length}</strong></div></CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card className="overflow-visible">
|
||||
<CardHeader className="border-b border-border"><CardTitle>监控明细</CardTitle><CardDescription>共 {sortedRows.length} 条 · 当前显示 {rangeStart}–{rangeEnd}</CardDescription><CardAction className="flex gap-2"><Button variant="outline" size="sm" onClick={() => setFilters(defaultFilters())}><RotateCcw />重置筛选</Button><Button size="sm" onClick={() => void exportMonitoringExcel(sortedRows)}><Download />导出 Excel</Button></CardAction></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<MultiSelectFilter label="银行" values={filters.bank} options={unique(models.map((model) => model.bank))} onChange={(value) => update("bank", value)} />
|
||||
<MultiSelectFilter label="是否无极银行" values={filters.wuji} options={["是", "否"]} onChange={(value) => update("wuji", value)} />
|
||||
<MultiSelectFilter label="模型名称" values={filters.name} options={unique(models.map((model) => model.name))} onChange={(value) => update("name", value)} />
|
||||
<MultiSelectFilter label="模型 ID" values={filters.modelId} options={unique(models.map((model) => model.modelId))} onChange={(value) => update("modelId", value)} />
|
||||
<MultiSelectFilter label="月份" values={filters.month} options={[...MONITOR_MONTHS].reverse()} onChange={(value) => update("month", value)} />
|
||||
<MultiSelectFilter label="模型状态" values={filters.status} options={["正常", "陪跑", "陪跑结束", "下线"]} onChange={(value) => update("status", value)} />
|
||||
<MultiSelectFilter label="模型异常等级" values={filters.abnormal} options={["三级", "二级", "一级", "正常"]} onChange={(value) => update("abnormal", value)} />
|
||||
<MultiSelectFilter label="监控结果等级" values={filters.grade} options={["A", "B", "C"]} onChange={(value) => update("grade", value)} />
|
||||
<MultiSelectFilter label="排序性" values={filters.ranking} options={["相符", "不符"]} onChange={(value) => update("ranking", value)} />
|
||||
<MultiSelectFilter label="KS" values={filters.ksBand} options={[">=40%", "<40%"]} onChange={(value) => update("ksBand", value)} />
|
||||
<MultiSelectFilter label="PSI" values={filters.psiBand} options={["<=10%", "10%-25%", ">25%"]} onChange={(value) => update("psiBand", value)} />
|
||||
<MultiSelectFilter label="KS 环比降幅" values={filters.dropBand} options={["<=20%", ">20%"]} onChange={(value) => update("dropBand", value)} />
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardContent className="overflow-visible px-0 pt-0">
|
||||
<Table className="table-fixed text-[11px]" containerClassName="overflow-visible">
|
||||
<colgroup><col className="w-[7%]" /><col className="w-[5%]" /><col className="w-[7%]" /><col className="w-[5%]" /><col className="w-[9%]" /><col className="w-[6%]" /><col className="w-[6%]" /><col className="w-[5%]" /><col className="w-[5%]" /><col className="w-[5%]" /><col className="w-[6%]" /><col className="w-[6%]" /><col className="w-[6%]" /><col className="w-[18%]" /></colgroup>
|
||||
<TableHeader className="[&_th]:h-auto [&_th]:whitespace-normal [&_th]:px-1 [&_th]:py-2 [&_th]:text-[10px]"><TableRow><SortableHead label="银行" sortKey="bank" sort={sort} onSort={changeSort} /><TableHead>无极银行</TableHead><TableHead>模型名称</TableHead><TableHead>版本</TableHead><SortableHead label="模型ID" sortKey="modelId" sort={sort} onSort={changeSort} /><SortableHead label="月份" sortKey="month" sort={sort} onSort={changeSort} /><TableHead>状态</TableHead><TableHead>排序性</TableHead><SortableHead label="KS" sortKey="ks" sort={sort} onSort={changeSort} /><SortableHead label="PSI" sortKey="psi" sort={sort} onSort={changeSort} /><TableHead>KS降幅</TableHead><TableHead>异常等级</TableHead><SortableHead label="结果等级" sortKey="grade" sort={sort} onSort={changeSort} /><TableHead>异常原因</TableHead></TableRow></TableHeader>
|
||||
<TableBody className="[&_td]:whitespace-normal [&_td]:px-1 [&_td]:py-2 [&_td]:leading-4">
|
||||
{pageRows.length ? pageRows.map((row) => <TableRow key={`${row.monitorMonth}-${row.modelId}`}><TableCell className="break-words font-medium text-foreground">{row.bank}</TableCell><TableCell>{row.wuji ? "是" : "否"}</TableCell><TableCell className="break-words">{categoryName(row.category)}</TableCell><TableCell>{row.version}</TableCell><TableCell className="break-all font-mono text-[10px]">{row.modelId}</TableCell><TableCell className="font-mono text-[10px]">{row.monitorMonth}</TableCell><TableCell><StatusBadge status={row.status} /></TableCell><TableCell className={row.ranking === "不符" ? "font-medium text-danger" : "text-success-strong"}>{row.ranking}</TableCell><TableCell className="tabular-nums">{row.ks.toFixed(2)}%</TableCell><TableCell className="tabular-nums">{row.psi.toFixed(2)}%</TableCell><TableCell className="tabular-nums">{row.ksDrop.toFixed(2)}%</TableCell><TableCell><AbnormalBadge level={abnormalLevelOf(row)} /></TableCell><TableCell><GradeBadge grade={gradeOf(row)} onClick={() => navigate(`/operations/monitoring/${row.modelId}`)} /></TableCell><TableCell className="whitespace-normal break-words text-muted-foreground">{abnormalReasonOf(row)}</TableCell></TableRow>) : <TableRow><TableCell colSpan={14} className="h-32 text-center text-muted-foreground">当前筛选条件下没有监控记录</TableCell></TableRow>}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
|
||||
<CardContent className="flex items-center justify-between border-t border-border py-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground"><span>每页</span><FilterSelect className="w-24" label="" value={String(pageSize)} allLabel="请选择" options={["10", "20", "50"]} onChange={(value) => setPageSize(Number(value))} /><span>条</span></div>
|
||||
<div className="flex items-center gap-2"><Button variant="outline" size="icon-sm" aria-label="上一页" disabled={safePage <= 1} onClick={() => setPage((value) => Math.max(1, value - 1))}><ChevronLeft /></Button><span className="min-w-20 text-center text-xs text-muted-foreground">{safePage} / {pageCount}</span><Button variant="outline" size="icon-sm" aria-label="下一页" disabled={safePage >= pageCount} onClick={() => setPage((value) => Math.min(pageCount, value + 1))}><ChevronRight /></Button></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { RefreshCw, TriangleAlert } from "lucide-react";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
import { useAuth } from "~/context/AuthContext";
|
||||
import {
|
||||
getMonthlyMonitoringResult,
|
||||
getOperationsModel,
|
||||
getOperationsWorkbench,
|
||||
listOperationsModels,
|
||||
OperationsApiError,
|
||||
type OperationsWorkbenchDto,
|
||||
operationsApiMode,
|
||||
type OperationsApiMode,
|
||||
} from "~/services/operationsApi";
|
||||
import type { ModelRecord, MonitoringRow } from "./modelData";
|
||||
|
||||
type OperationsDataContextValue = {
|
||||
models: ModelRecord[];
|
||||
workbench: OperationsWorkbenchDto;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
source: OperationsApiMode;
|
||||
reload: () => Promise<void>;
|
||||
};
|
||||
|
||||
const OperationsDataContext = createContext<OperationsDataContextValue | null>(null);
|
||||
|
||||
const EMPTY_WORKBENCH: OperationsWorkbenchDto = {
|
||||
role: "model_team",
|
||||
alerts: [],
|
||||
kpis: [],
|
||||
todos: [],
|
||||
watches: [],
|
||||
activities: [],
|
||||
};
|
||||
|
||||
export function OperationsDataProvider({ children }: { children: ReactNode }) {
|
||||
const { currentWorkspace } = useAuth();
|
||||
const workspaceId = currentWorkspace?.workspace_id;
|
||||
const [models, setModels] = useState<ModelRecord[]>([]);
|
||||
const [workbench, setWorkbench] = useState<OperationsWorkbenchDto>(EMPTY_WORKBENCH);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (signal?: AbortSignal) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [modelsResult, workbenchResult] = await Promise.allSettled([
|
||||
listOperationsModels({ workspaceId, signal }),
|
||||
getOperationsWorkbench(workspaceId, signal),
|
||||
]);
|
||||
if (modelsResult.status === "rejected") throw modelsResult.reason;
|
||||
setModels(modelsResult.value);
|
||||
setWorkbench(workbenchResult.status === "fulfilled" ? workbenchResult.value : EMPTY_WORKBENCH);
|
||||
} catch (cause) {
|
||||
if (cause instanceof DOMException && cause.name === "AbortError") return;
|
||||
setModels([]);
|
||||
setError(cause instanceof Error ? cause.message : "模型数据加载失败");
|
||||
} finally {
|
||||
if (!signal?.aborted) setLoading(false);
|
||||
}
|
||||
}, [workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void load(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [load]);
|
||||
|
||||
const value = useMemo<OperationsDataContextValue>(() => ({
|
||||
models,
|
||||
workbench,
|
||||
loading,
|
||||
error,
|
||||
source: operationsApiMode,
|
||||
reload: () => load(),
|
||||
}), [error, load, loading, models, workbench]);
|
||||
|
||||
return <OperationsDataContext.Provider value={value}>{children}</OperationsDataContext.Provider>;
|
||||
}
|
||||
|
||||
export function useOperationsData(): OperationsDataContextValue {
|
||||
const context = useContext(OperationsDataContext);
|
||||
if (!context) throw new Error("useOperationsData 必须在 OperationsDataProvider 内使用");
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useOperationsModelDetail(modelId: string, month: string) {
|
||||
const { currentWorkspace } = useAuth();
|
||||
const workspaceId = currentWorkspace?.workspace_id;
|
||||
const [model, setModel] = useState<ModelRecord | null>(null);
|
||||
const [monitoringResult, setMonitoringResult] = useState<MonitoringRow | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void Promise.all([
|
||||
getOperationsModel(modelId, workspaceId, controller.signal),
|
||||
getMonthlyMonitoringResult(modelId, month, workspaceId, controller.signal).catch((cause) => {
|
||||
if (cause instanceof OperationsApiError && cause.code === "MONITOR_RESULT_NOT_FOUND") {
|
||||
return null;
|
||||
}
|
||||
throw cause;
|
||||
}),
|
||||
]).then(([modelValue, resultValue]) => {
|
||||
setModel(modelValue);
|
||||
setMonitoringResult(resultValue);
|
||||
}).catch((cause) => {
|
||||
if (cause instanceof DOMException && cause.name === "AbortError") return;
|
||||
setError(cause instanceof Error ? cause.message : "模型监控详情加载失败");
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [modelId, month, reloadKey, workspaceId]);
|
||||
|
||||
return {
|
||||
model,
|
||||
monitoringResult,
|
||||
loading,
|
||||
error,
|
||||
reload: () => setReloadKey((value) => value + 1),
|
||||
};
|
||||
}
|
||||
|
||||
export function OperationsDataBoundary({ children }: { children: ReactNode }) {
|
||||
const { loading, error, reload } = useOperationsData();
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="h-full overflow-auto bg-bg p-6" aria-busy="true" aria-label="正在加载运维数据">
|
||||
<div className="w-full space-y-6">
|
||||
<div className="space-y-3"><Skeleton className="h-3 w-48" /><Skeleton className="h-8 w-80" /><Skeleton className="h-4 w-[36rem]" /></div>
|
||||
<Skeleton className="h-40 w-full rounded-4xl" />
|
||||
<div className="grid grid-cols-4 gap-4">{Array.from({ length: 4 }, (_, index) => <Skeleton className="h-32 rounded-4xl" key={index} />)}</div>
|
||||
<div className="grid grid-cols-2 gap-6"><Skeleton className="h-72 rounded-4xl" /><Skeleton className="h-72 rounded-4xl" /></div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section className="grid h-full place-items-center bg-bg p-6">
|
||||
<Card className="w-full max-w-xl">
|
||||
<CardContent className="flex flex-col items-center py-12 text-center">
|
||||
<span className="grid size-12 place-items-center rounded-2xl bg-danger-soft text-danger"><TriangleAlert className="size-6" /></span>
|
||||
<h2 className="mt-4 text-xl font-bold text-foreground">运维数据加载失败</h2>
|
||||
<p className="mt-2 max-w-md text-sm leading-6 text-muted-foreground">{error}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">当前数据源:真实接口</p>
|
||||
<Button className="mt-5" onClick={() => void reload()}><RefreshCw />重新加载</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { ArrowLeft, ChevronDown } from "lucide-react";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { cn } from "~/lib/utils";
|
||||
import type { AbnormalLevel, ModelGrade, ModelStatus } from "./modelData";
|
||||
|
||||
export function OperationsPageHeader({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
onBack,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
actions?: ReactNode;
|
||||
onBack?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<header className="flex items-start justify-between gap-6">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
{onBack && (
|
||||
<Button aria-label="返回" variant="outline" size="icon-sm" onClick={onBack}>
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<span className="text-2xs font-medium-plus tracking-[0.08em] text-primary">
|
||||
A CARD MODEL OPERATIONS
|
||||
</span>
|
||||
<h2 className="mt-1 text-2xl font-bold tracking-tight text-foreground">{title}</h2>
|
||||
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
const gradeClasses: Record<ModelGrade, string> = {
|
||||
A: "bg-success-soft text-success-strong hover:bg-success-soft/80",
|
||||
B: "bg-warning-soft text-warning hover:bg-warning-soft/80",
|
||||
C: "bg-danger-soft text-danger hover:bg-danger-soft/80",
|
||||
};
|
||||
|
||||
export function GradeBadge({
|
||||
grade,
|
||||
onClick,
|
||||
suffix,
|
||||
}: {
|
||||
grade: ModelGrade;
|
||||
onClick?: () => void;
|
||||
suffix?: string;
|
||||
}) {
|
||||
const label = `${grade}${suffix ? ` ${suffix}` : ""}`;
|
||||
if (onClick) {
|
||||
return (
|
||||
<Button
|
||||
className={cn("min-w-8 font-semibold", gradeClasses[grade])}
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
data-slot="grade-badge"
|
||||
className={cn("inline-flex h-6 items-center rounded-full px-2.5 text-xs font-semibold", gradeClasses[grade])}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const abnormalClasses: Record<AbnormalLevel, string> = {
|
||||
三级: "bg-danger-soft text-danger",
|
||||
二级: "bg-warning-soft text-warning",
|
||||
一级: "bg-brand-soft text-primary",
|
||||
正常: "bg-success-soft text-success-strong",
|
||||
};
|
||||
|
||||
export function AbnormalBadge({ level }: { level: AbnormalLevel }) {
|
||||
return (
|
||||
<span
|
||||
data-slot="abnormal-badge"
|
||||
className={cn("inline-flex h-6 items-center rounded-full px-2.5 text-xs font-medium", abnormalClasses[level])}
|
||||
>
|
||||
{level}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const statusClasses: Record<ModelStatus, string> = {
|
||||
正常: "bg-success-soft text-success-strong",
|
||||
陪跑: "bg-brand-soft text-primary",
|
||||
陪跑结束: "bg-muted text-muted-foreground",
|
||||
下线: "bg-muted text-muted-foreground",
|
||||
};
|
||||
|
||||
export function StatusBadge({ status }: { status: ModelStatus }) {
|
||||
return (
|
||||
<span
|
||||
data-slot="model-status-badge"
|
||||
className={cn("inline-flex h-6 items-center rounded-full px-2.5 text-xs font-medium", statusClasses[status])}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterSelect({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
allLabel = "全部",
|
||||
className,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
options: Array<string | { label: string; value: string }>;
|
||||
onChange: (value: string) => void;
|
||||
allLabel?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className={cn("flex min-w-0 flex-col gap-1.5", className)}>
|
||||
<span className="text-xs font-medium text-ink-caption">{label}</span>
|
||||
<span className="relative">
|
||||
<select
|
||||
data-slot="operations-filter-select"
|
||||
disabled={disabled}
|
||||
className="h-9 w-full appearance-none rounded-3xl border border-transparent bg-input/50 px-3 pr-8 text-sm text-foreground outline-none transition-colors focus:border-ring"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
<option value="">{allLabel}</option>
|
||||
{options.map((option) => {
|
||||
const item = typeof option === "string" ? { label: option, value: option } : option;
|
||||
return <option key={item.value} value={item.value}>{item.label}</option>;
|
||||
})}
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function MultiSelectFilter({
|
||||
label,
|
||||
values,
|
||||
options,
|
||||
onChange,
|
||||
allLabel = "全部",
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
values: string[];
|
||||
options: Array<string | { label: string; value: string }>;
|
||||
onChange: (values: string[]) => void;
|
||||
allLabel?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const normalized = options.map((option) => typeof option === "string" ? { label: option, value: option } : option);
|
||||
const selectedLabels = normalized.filter((option) => values.includes(option.value)).map((option) => option.label);
|
||||
const summary = !selectedLabels.length
|
||||
? allLabel
|
||||
: selectedLabels.length <= 2
|
||||
? selectedLabels.join("、")
|
||||
: `已选 ${selectedLabels.length} 项`;
|
||||
|
||||
const toggle = (value: string) => {
|
||||
onChange(values.includes(value) ? values.filter((item) => item !== value) : [...values, value]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex min-w-0 flex-col gap-1.5", className)} data-slot="operations-multi-select">
|
||||
<span className="text-xs font-medium text-ink-caption">{label}</span>
|
||||
<details className="group relative">
|
||||
<summary className="flex h-9 cursor-pointer list-none items-center justify-between gap-2 rounded-3xl border border-transparent bg-input/50 px-3 text-sm text-foreground outline-none transition-colors hover:bg-input focus-visible:border-ring [&::-webkit-details-marker]:hidden">
|
||||
<span className="truncate">{summary}</span>
|
||||
<ChevronDown className="size-4 shrink-0 text-muted-foreground transition-transform group-open:rotate-180" />
|
||||
</summary>
|
||||
<div className="absolute left-0 z-30 mt-1.5 min-w-full rounded-3xl bg-popover p-2 shadow-lg ring-1 ring-foreground/5">
|
||||
<div className="mb-1 flex items-center justify-between border-b border-border px-2 pb-2">
|
||||
<span className="text-xs text-muted-foreground">{values.length ? `已选 ${values.length} 项` : allLabel}</span>
|
||||
{values.length > 0 && <button className="text-xs font-medium text-primary hover:underline" type="button" onClick={() => onChange([])}>清空</button>}
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto py-1">
|
||||
{normalized.map((option) => (
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded-xl px-2 py-2 text-sm hover:bg-muted/70" key={option.value}>
|
||||
<input className="size-4 accent-brand" type="checkbox" checked={values.includes(option.value)} onChange={() => toggle(option.value)} />
|
||||
<span className="whitespace-nowrap">{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Threshold = {
|
||||
value: number;
|
||||
label: string;
|
||||
tone?: "warning" | "danger";
|
||||
};
|
||||
|
||||
export function MetricLineChart({
|
||||
months,
|
||||
values,
|
||||
name,
|
||||
unit = "%",
|
||||
thresholds = [],
|
||||
comparison,
|
||||
}: {
|
||||
months: string[];
|
||||
values: number[];
|
||||
name: string;
|
||||
unit?: string;
|
||||
thresholds?: Threshold[];
|
||||
comparison?: { name: string; values: number[]; tone?: "success" | "warning" };
|
||||
}) {
|
||||
if (!months.length || !values.length) {
|
||||
return <div data-slot="metric-line-chart" className="grid min-h-48 place-items-center rounded-xl border border-dashed border-border text-sm text-muted-foreground">暂无指标趋势数据</div>;
|
||||
}
|
||||
const width = 640;
|
||||
const height = 190;
|
||||
const left = 48;
|
||||
const right = 20;
|
||||
const top = 18;
|
||||
const bottom = 34;
|
||||
const plotWidth = width - left - right;
|
||||
const plotHeight = height - top - bottom;
|
||||
const allValues = [...values, ...(comparison?.values ?? []), ...thresholds.map((item) => item.value)];
|
||||
const rawMin = Math.min(...allValues);
|
||||
const rawMax = Math.max(...allValues);
|
||||
const padding = Math.max(1, (rawMax - rawMin) * 0.16);
|
||||
const min = Math.max(0, rawMin - padding);
|
||||
const max = rawMax + padding;
|
||||
const range = Math.max(1, max - min);
|
||||
const x = (index: number) => left + (months.length <= 1 ? plotWidth / 2 : index * plotWidth / (months.length - 1));
|
||||
const y = (value: number) => top + (max - value) / range * plotHeight;
|
||||
const path = values.map((value, index) => `${index === 0 ? "M" : "L"}${x(index)},${y(value)}`).join(" ");
|
||||
const comparisonPath = comparison?.values.map((value, index) => `${index === 0 ? "M" : "L"}${x(index)},${y(value)}`).join(" ") ?? "";
|
||||
const ticks = Array.from({ length: 4 }, (_, index) => max - range * index / 3);
|
||||
|
||||
return (
|
||||
<div data-slot="metric-line-chart" className="w-full">
|
||||
<svg className="h-auto w-full" viewBox={`0 0 ${width} ${height}`} role="img" aria-label={`${name}趋势图`}>
|
||||
{ticks.map((tick) => (
|
||||
<g key={tick}>
|
||||
<line className="stroke-border" x1={left} x2={width - right} y1={y(tick)} y2={y(tick)} />
|
||||
<text className="fill-ink-subtle text-3xs" x={left - 8} y={y(tick) + 3} textAnchor="end">
|
||||
{tick.toFixed(1)}{unit}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{thresholds.map((threshold) => (
|
||||
<g key={threshold.label}>
|
||||
<line
|
||||
className={cn(
|
||||
"[stroke-dasharray:6_5] [stroke-width:1.5]",
|
||||
threshold.tone === "danger" ? "stroke-danger" : "stroke-warning",
|
||||
)}
|
||||
x1={left}
|
||||
x2={width - right}
|
||||
y1={y(threshold.value)}
|
||||
y2={y(threshold.value)}
|
||||
/>
|
||||
<text
|
||||
className={threshold.tone === "danger" ? "fill-danger text-3xs" : "fill-warning text-3xs"}
|
||||
x={width - right}
|
||||
y={y(threshold.value) - 5}
|
||||
textAnchor="end"
|
||||
>
|
||||
{threshold.label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
<path className="fill-none stroke-primary [stroke-linecap:round] [stroke-linejoin:round] [stroke-width:3]" d={path} />
|
||||
{values.map((value, index) => (
|
||||
<circle
|
||||
className="fill-card stroke-primary [stroke-width:3]"
|
||||
key={`${months[index]}-${value}`}
|
||||
cx={x(index)}
|
||||
cy={y(value)}
|
||||
r="4"
|
||||
/>
|
||||
))}
|
||||
{comparison && <path className={cn("fill-none [stroke-dasharray:7_5] [stroke-linecap:round] [stroke-linejoin:round] [stroke-width:2.5]", comparison.tone === "warning" ? "stroke-warning" : "stroke-success")} d={comparisonPath} />}
|
||||
{comparison?.values.map((value, index) => (
|
||||
<circle className={cn("fill-card [stroke-width:2.5]", comparison.tone === "warning" ? "stroke-warning" : "stroke-success")} key={`comparison-${months[index]}-${value}`} cx={x(index)} cy={y(value)} r="3.5" />
|
||||
))}
|
||||
{months.map((month, index) => (
|
||||
<text
|
||||
className="fill-ink-subtle text-3xs"
|
||||
key={month}
|
||||
x={x(index)}
|
||||
y={height - 10}
|
||||
textAnchor="middle"
|
||||
>
|
||||
{month}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<i className="h-0.5 w-5 rounded-full bg-primary" />
|
||||
{name}
|
||||
{comparison && <><i className={cn("ml-3 h-0.5 w-5 rounded-full", comparison.tone === "warning" ? "bg-warning" : "bg-success")} />{comparison.name}</>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SortingComboChart({
|
||||
bins,
|
||||
counts,
|
||||
badRates,
|
||||
}: {
|
||||
bins: readonly string[];
|
||||
counts: readonly number[];
|
||||
badRates: readonly number[];
|
||||
}) {
|
||||
if (!bins.length || !counts.length || !badRates.length) {
|
||||
return <div data-slot="sorting-combo-chart" className="grid min-h-64 place-items-center rounded-xl border border-dashed border-border text-sm text-muted-foreground">暂无排序性分布数据</div>;
|
||||
}
|
||||
const width = 760;
|
||||
const height = 300;
|
||||
const left = 56;
|
||||
const right = 54;
|
||||
const top = 22;
|
||||
const bottom = 52;
|
||||
const plotWidth = width - left - right;
|
||||
const plotHeight = height - top - bottom;
|
||||
const maxCount = Math.ceil(Math.max(...counts) / 1000) * 1000;
|
||||
const maxRate = 12;
|
||||
const step = plotWidth / bins.length;
|
||||
const barWidth = step * 0.34;
|
||||
const x = (index: number) => left + step * index + step / 2;
|
||||
const countY = (value: number) => top + (1 - value / maxCount) * plotHeight;
|
||||
const rateY = (value: number) => top + (1 - value / maxRate) * plotHeight;
|
||||
const linePath = badRates.map((value, index) => `${index === 0 ? "M" : "L"}${x(index)},${rateY(value)}`).join(" ");
|
||||
const rateTicks = [0, 2, 4, 6, 8, 10, 12];
|
||||
|
||||
return (
|
||||
<div data-slot="sorting-combo-chart" className="w-full">
|
||||
<svg className="h-auto w-full" viewBox={`0 0 ${width} ${height}`} role="img" aria-label="排序性趋势(当月)">
|
||||
{rateTicks.map((tick) => (
|
||||
<g key={tick}>
|
||||
<line className="stroke-border" x1={left} x2={width - right} y1={rateY(tick)} y2={rateY(tick)} />
|
||||
<text className="fill-ink-subtle text-3xs" x={left - 8} y={rateY(tick) + 3} textAnchor="end">{tick.toFixed(0)}%</text>
|
||||
<text className="fill-ink-subtle text-3xs" x={width - right + 8} y={rateY(tick) + 3} textAnchor="start">
|
||||
{Math.round(tick / maxRate * maxCount)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{counts.map((count, index) => (
|
||||
<g key={`${bins[index]}-${count}`}>
|
||||
<rect
|
||||
className="fill-primary/75"
|
||||
x={x(index) - barWidth / 2}
|
||||
y={countY(count)}
|
||||
width={barWidth}
|
||||
height={top + plotHeight - countY(count)}
|
||||
rx="3"
|
||||
/>
|
||||
<text className="fill-foreground text-3xs" x={x(index)} y={countY(count) - 7} textAnchor="middle">{count}</text>
|
||||
<text className="fill-ink-muted text-3xs" x={x(index)} y={height - 24} textAnchor="middle">{bins[index]}</text>
|
||||
</g>
|
||||
))}
|
||||
<path className="fill-none stroke-warning [stroke-linecap:round] [stroke-linejoin:round] [stroke-width:3]" d={linePath} />
|
||||
{badRates.map((rate, index) => (
|
||||
<g key={`${bins[index]}-${rate}`}>
|
||||
<circle className="fill-warning" cx={x(index)} cy={rateY(rate)} r="4" />
|
||||
<text className="fill-foreground text-3xs" x={x(index) + 6} y={rateY(rate) - 8}>{rate.toFixed(2)}%</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
<div className="mt-2 flex justify-center gap-6 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-2"><i className="h-2.5 w-5 rounded bg-primary/75" />客户数</span>
|
||||
<span className="flex items-center gap-2"><i className="h-0.5 w-5 rounded bg-warning" />坏客户占比</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import type { OperationsWorkbenchItem, OperationsWorkbenchKpi } from "~/services/operationsApi";
|
||||
import { useOperationsData } from "./OperationsDataContext";
|
||||
|
||||
const FALLBACK_KPIS: OperationsWorkbenchKpi[] = [
|
||||
{ key: "models", label: "在管模型", value: 0, detail: "0 家银行 · 0 个大类", target: "/operations/models", tone: "normal" },
|
||||
{ key: "grades", label: "B / C 等级", value: 0, detail: "C 0 · B 0", target: "/operations/monitoring?grade=B,C", tone: "normal" },
|
||||
{ key: "pending_reviews", label: "待处理结果", value: 0, detail: "到期未处理即默认暂不处理", target: "/operations/monitoring", tone: "normal" },
|
||||
{ key: "intervention", label: "需主动干预", value: 0, detail: "KS < 30% 或 PSI > 50%", target: "/operations/monitoring", tone: "normal" },
|
||||
{ key: "model_reports", label: "待我阅读报告", value: 0, detail: "超过 5 个工作日进入催办", target: "/operations/reports", tone: "normal" },
|
||||
{ key: "stalled_workflows", label: "流程停滞", value: 0, detail: "超过节点期限未更新", target: "/operations/workflows", tone: "normal" },
|
||||
];
|
||||
|
||||
const kpiToneClasses = {
|
||||
normal: "border-border bg-card",
|
||||
primary: "border-primary/25 bg-card",
|
||||
warning: "border-warning/35 bg-[#fffaf5]",
|
||||
danger: "border-danger/35 bg-[#fff7f6]",
|
||||
} as const;
|
||||
|
||||
const itemToneClasses = {
|
||||
normal: "bg-border",
|
||||
primary: "bg-primary",
|
||||
warning: "bg-warning",
|
||||
danger: "bg-danger",
|
||||
} as const;
|
||||
|
||||
function WorkbenchItem({ item, onNavigate }: { item: OperationsWorkbenchItem; onNavigate: (target: string) => void }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 border-b border-dashed border-border-2 px-4 py-3 last:border-b-0">
|
||||
<span className={`mt-1.5 h-8 w-1 shrink-0 rounded-full ${itemToneClasses[item.tone]}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-foreground">{item.title}</div>
|
||||
<div className="mt-0.5 text-xs leading-5 text-muted-foreground">{item.detail}</div>
|
||||
</div>
|
||||
<Button
|
||||
className="shrink-0"
|
||||
variant={item.tone === "danger" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onNavigate(item.target)}
|
||||
>
|
||||
{item.action_label}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyList({ text }: { text: string }) {
|
||||
return <div className="flex min-h-[72px] items-center justify-center px-4 py-6 text-center text-xs text-muted-foreground">{text}</div>;
|
||||
}
|
||||
|
||||
export default function OperationsWorkbenchPage() {
|
||||
const navigate = useNavigate();
|
||||
const { workbench } = useOperationsData();
|
||||
const kpis = workbench.kpis.length === 6 ? workbench.kpis : FALLBACK_KPIS;
|
||||
const todos = workbench.todos;
|
||||
const watches = workbench.watches;
|
||||
const activities = workbench.activities;
|
||||
const serviceOnline = workbench.kpis.length === 6;
|
||||
|
||||
return (
|
||||
<section className="h-full overflow-auto bg-bg px-6 py-5">
|
||||
<div className="flex w-full flex-col gap-4 pb-8">
|
||||
<section className="dashboard-hero-shell flex flex-col items-start justify-between gap-6 rounded-xl px-[34px] py-[30px] text-white shadow-lg sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<span className="text-2xs tracking-[0.12em] opacity-80">MODEL OPERATIONS PLATFORM</span>
|
||||
<h2 className="my-2 mb-[5px] text-2xl font-medium">运维工作台</h2>
|
||||
<p className="m-0 text-xs opacity-90">一屏内可见 KPI、我的待办、我的关注与最近动态</p>
|
||||
</div>
|
||||
<span className="rounded-[20px] bg-white/16 px-3 py-2 text-sm">
|
||||
{serviceOnline ? "服务已连接" : "服务连接中"}
|
||||
</span>
|
||||
</section>
|
||||
|
||||
{workbench.alerts.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-danger/25 bg-danger-soft px-4 py-3 text-sm text-danger">
|
||||
<span className="size-2 shrink-0 rounded-full bg-danger" />
|
||||
<span className="flex-1">{workbench.alerts.join(" ")}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => navigate("/operations/monitoring")}>查看监控概览</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-6 gap-3 max-[1180px]:grid-cols-3">
|
||||
{kpis.map((kpi) => (
|
||||
<button
|
||||
className={`min-w-0 rounded-lg border p-4 text-left shadow-sm transition-colors hover:border-primary/45 ${kpiToneClasses[kpi.tone]}`}
|
||||
key={kpi.key}
|
||||
type="button"
|
||||
onClick={() => navigate(kpi.target)}
|
||||
title="点击查看明细"
|
||||
>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<span className="truncate">{kpi.label}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-end gap-1">
|
||||
<strong className="text-2xl font-semibold tabular-nums text-foreground">{kpi.value}</strong>
|
||||
<span className="mb-0.5 text-lg leading-none text-muted-foreground">›</span>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground">{kpi.detail}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 max-[1180px]:grid-cols-1">
|
||||
<div className="min-h-[144px] overflow-hidden rounded-lg border border-border bg-card shadow-sm">
|
||||
<div className="flex items-center gap-3 border-b border-border-2 px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-foreground">我的待办</h2>
|
||||
<span className="text-xs text-muted-foreground">按催办状态 / 截止日期 / 监控结果等级排序</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">共 {todos.length} 项</span>
|
||||
</div>
|
||||
{todos.length ? todos.map((item) => <WorkbenchItem item={item} key={item.id} onNavigate={navigate} />) : <EmptyList text="暂无待办" />}
|
||||
</div>
|
||||
|
||||
<div className="min-h-[144px] overflow-hidden rounded-lg border border-border bg-card shadow-sm">
|
||||
<div className="flex items-center gap-3 border-b border-border-2 px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-foreground">我的关注</h2>
|
||||
<span className="text-xs text-muted-foreground">只收纳“查看进度”类事项,不计入待办</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">共 {watches.length} 项</span>
|
||||
</div>
|
||||
{watches.length ? watches.map((item) => <WorkbenchItem item={item} key={item.id} onNavigate={navigate} />) : <EmptyList text="暂无关注事项" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[156px] overflow-hidden rounded-lg border border-border bg-card shadow-sm">
|
||||
<div className="border-b border-border-2 px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-foreground">最近动态</h2>
|
||||
</div>
|
||||
{activities.length ? (
|
||||
<ul className="divide-y divide-dashed divide-border-2 px-4">
|
||||
{activities.map((item, index) => (
|
||||
<li className="flex gap-4 py-3 text-sm" key={`${item.occurred_at}-${index}`}>
|
||||
<time className="w-36 shrink-0 text-xs tabular-nums text-muted-foreground">{item.occurred_at.replace("T", " ").slice(0, 16)}</time>
|
||||
<span className="min-w-0 text-foreground"><b className="font-medium text-primary">{item.actor}</b> {item.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : <EmptyList text="暂无最近动态" />}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Fragment, useState } from "react";
|
||||
import { CheckCircle2, Edit3, Save, Sparkles } 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { FilterSelect, OperationsPageHeader } from "./OperationsUi";
|
||||
import { PROMPTS, PROMPT_REGRESSION, PROMPT_VERSIONS, type PromptKey } from "./governanceData";
|
||||
import { useCanOperationsAction } from "./operationsRole";
|
||||
|
||||
function PromptPreview({ text }: { text: string }) {
|
||||
const parts = text.split(/(\{\{[^}]+\}\})/g);
|
||||
return <pre className="whitespace-pre-wrap rounded-xl bg-bg-log p-5 font-mono text-xs leading-6 text-white/80">{parts.map((part, index) => part.startsWith("{{") ? <mark className="rounded bg-warning/20 px-1 text-warning" key={`${part}-${index}`}>{part}</mark> : <Fragment key={`${part}-${index}`}>{part}</Fragment>)}</pre>;
|
||||
}
|
||||
|
||||
export default function PromptManagementPage() {
|
||||
const canEdit = useCanOperationsAction("prompts:manage");
|
||||
const canRegression = useCanOperationsAction("prompts:regression");
|
||||
const [promptKey, setPromptKey] = useState<PromptKey>("BC");
|
||||
const [texts, setTexts] = useState<Record<PromptKey, string>>({ A: "", BC: "" });
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [versions] = useState(PROMPT_VERSIONS);
|
||||
const prompt = PROMPTS[promptKey];
|
||||
const passed = PROMPT_REGRESSION.filter((item) => item.result === "通过").length;
|
||||
|
||||
const savePrompt = () => {
|
||||
toast.info("Prompt保存接口尚未接入");
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="h-full overflow-auto bg-bg p-6">
|
||||
<div className="flex w-full flex-col gap-6 pb-8">
|
||||
<OperationsPageHeader title="报告 Prompt 管理" description="查看和调整提示词全文,保留版本记录,并通过历史样本回归后生效。" actions={canRegression ? <Button onClick={savePrompt}><Save />保存并提交回归</Button> : undefined} />
|
||||
<div className="rounded-xl bg-brand-soft p-4 text-sm leading-6 text-primary">提示词查看与调整属于需求沟通补充项,建议在下一版需求文档中同步固化。平台注入变量由程序取数,不交由大模型计算。</div>
|
||||
|
||||
<div className="grid grid-cols-[minmax(0,1.5fr)_minmax(20rem,0.7fr)] gap-6">
|
||||
<Card>
|
||||
<CardHeader className="border-b border-border"><CardTitle className="flex items-center gap-2"><Sparkles className="size-5 text-primary" />提示词全文</CardTitle><CardDescription>变量占位符必须保持双花括号格式</CardDescription><CardAction className="flex items-end gap-2"><FilterSelect className="w-72" label="报告模板" value={promptKey} allLabel="暂无模板" options={Object.entries(PROMPTS).map(([value, item]) => ({ label: item.name, value }))} onChange={(value) => { setPromptKey(value as PromptKey); setEditing(false); }} /><Button variant="outline" size="sm" disabled={!prompt || !canEdit} onClick={() => setEditing((value) => !value)}><Edit3 />{editing ? "退出编辑" : "编辑"}</Button></CardAction></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{prompt ? (editing ? <Textarea className="min-h-[36rem] font-mono text-xs leading-6" value={texts[promptKey]} onChange={(event) => setTexts((current) => ({ ...current, [promptKey]: event.target.value }))} /> : <PromptPreview text={texts[promptKey]} />) : <div className="grid min-h-[36rem] place-items-center rounded-xl border border-dashed border-border text-sm text-muted-foreground">暂无 Prompt 数据</div>}
|
||||
<p className="rounded-xl bg-muted/50 p-4 text-xs leading-5 text-muted-foreground">高亮内容为程序注入变量。等级字段仅用于选择模板,不进入报告正文;Prompt 禁止自行计算指标或臆测业务原因。</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-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="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> : <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">暂无 Prompt 版本数据</div>}</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>回归评审</CardTitle><CardDescription>上线前使用历史样本回归</CardDescription></CardHeader>
|
||||
<CardContent className="grid grid-cols-3 gap-3"><div className="rounded-xl bg-muted/50 p-3"><span className="text-xs text-muted-foreground">回归样本</span><strong className="mt-1 block text-xl tabular-nums">{PROMPT_REGRESSION.length}</strong></div><div className="rounded-xl bg-success-soft p-3"><span className="text-xs text-success-strong">通过</span><strong className="mt-1 block text-xl tabular-nums text-success-strong">{passed}</strong></div><div className="rounded-xl bg-warning-soft p-3"><span className="text-xs text-warning">待复核</span><strong className="mt-1 block text-xl tabular-nums text-warning">{PROMPT_REGRESSION.length - passed}</strong></div></CardContent>
|
||||
<CardContent className="px-0 pt-0"><Table><TableHeader><TableRow><TableHead>历史样本</TableHead><TableHead>结果</TableHead><TableHead>说明</TableHead></TableRow></TableHeader><TableBody>{PROMPT_REGRESSION.map((item) => <TableRow key={item.sample}><TableCell className="min-w-48 whitespace-normal">{item.sample}</TableCell><TableCell>{item.result === "通过" ? <span className="inline-flex items-center gap-1 rounded-full bg-success-soft px-2.5 py-1 text-xs text-success-strong"><CheckCircle2 className="size-3" />通过</span> : <span className="rounded-full bg-warning-soft px-2.5 py-1 text-xs text-warning">待复核</span>}</TableCell><TableCell className="whitespace-normal text-muted-foreground">{item.detail}</TableCell></TableRow>)}</TableBody></Table></CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Download, FileCheck2, Save, Send, TriangleAlert } from "lucide-react";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
|
||||
import { FilterSelect, MetricLineChart, OperationsPageHeader, SortingComboChart } from "./OperationsUi";
|
||||
import { FEATURE_METRICS, MONITOR_MONTHS, SORTING_DISTRIBUTION, modelTrend } from "./modelData";
|
||||
import { useOperationsData } from "./OperationsDataContext";
|
||||
import { useCanOperationsAction } from "./operationsRole";
|
||||
import { latestReports, type ReportStatus, type ReportType } from "./reportData";
|
||||
import { useReportStore } from "./reportStore";
|
||||
|
||||
type Filters = { bank: string; name: string; version: string; modelId: string };
|
||||
|
||||
function unique(values: string[]): string[] {
|
||||
return [...new Set(values)].sort((left, right) => left.localeCompare(right, "zh-CN"));
|
||||
}
|
||||
|
||||
function statusClass(status: ReportStatus): string {
|
||||
if (status === "已发送业务团队") return "bg-success-soft text-success-strong";
|
||||
if (status === "编辑中") return "bg-brand-soft text-primary";
|
||||
return "bg-warning-soft text-warning";
|
||||
}
|
||||
|
||||
function ReportTypeBadge({ type }: { type: ReportType }) {
|
||||
return <span className={type === "监控报告" ? "inline-flex rounded-full bg-success-soft px-2.5 py-1 text-xs font-medium text-success-strong" : "inline-flex rounded-full bg-warning-soft px-2.5 py-1 text-xs font-medium text-warning"}>{type}</span>;
|
||||
}
|
||||
|
||||
function ScoreDistributionTable() {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>分数区间</TableHead><TableHead>总账户数</TableHead><TableHead>账户占比</TableHead><TableHead>账户累计占比</TableHead></TableRow></TableHeader>
|
||||
<TableBody><TableRow><TableCell className="h-28 text-center text-muted-foreground" colSpan={4}>暂无评分分布数据</TableCell></TableRow></TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ReportPage() {
|
||||
const navigate = useNavigate();
|
||||
const { models } = useOperationsData();
|
||||
const [searchParams] = useSearchParams();
|
||||
const requestedId = searchParams.get("report");
|
||||
const allReports = useReportStore((state) => state.reports);
|
||||
const updateReport = useReportStore((state) => state.updateReport);
|
||||
const reports = requestedId && allReports.some((report) => report.reportId === requestedId) ? allReports : latestReports(allReports);
|
||||
const [filters, setFilters] = useState<Filters>({ bank: "", name: "", version: "", modelId: "" });
|
||||
const [selectedId, setSelectedId] = useState(requestedId ?? reports[0]?.reportId ?? "");
|
||||
const pool = reports.filter((report) => (
|
||||
(!filters.bank || report.bank === filters.bank)
|
||||
&& (!filters.name || report.modelName === filters.name)
|
||||
&& (!filters.version || report.version === filters.version)
|
||||
&& (!filters.modelId || report.modelId === filters.modelId)
|
||||
));
|
||||
const report = pool.find((item) => item.reportId === selectedId) ?? pool[0] ?? null;
|
||||
const model = report ? models.find((item) => item.modelId === report.modelId) ?? null : null;
|
||||
const isHistorical = Boolean(report && report.monitorMonth !== "2026-07");
|
||||
const ksTrend = model ? modelTrend(model, "ks") : [];
|
||||
const psiTrend = model ? modelTrend(model, "psi") : [];
|
||||
const canEdit = !isHistorical && useCanOperationsAction("report:edit");
|
||||
const canExport = useCanOperationsAction("report:export");
|
||||
const canSend = !isHistorical && useCanOperationsAction("report:send");
|
||||
|
||||
const updateFilter = <K extends keyof Filters>(key: K, value: Filters[K]) => {
|
||||
setFilters((current) => ({ ...current, [key]: value }));
|
||||
};
|
||||
|
||||
const reportOptions = useMemo(() => pool.map((item) => ({
|
||||
label: `${item.bank} · ${item.modelName} ${item.version} · ${item.monitorMonth} · ${item.type}`,
|
||||
value: item.reportId,
|
||||
})), [pool]);
|
||||
|
||||
const printReport = () => {
|
||||
document.body.classList.add("printing-operations-report");
|
||||
window.print();
|
||||
window.setTimeout(() => document.body.classList.remove("printing-operations-report"), 300);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="h-full overflow-auto bg-bg p-6">
|
||||
<div className="flex w-full flex-col gap-6 pb-8">
|
||||
<OperationsPageHeader
|
||||
title={report ? `${report.type} · ${report.bank} ${report.modelName}` : "监控诊断报告"}
|
||||
description={report ? `${report.monitorMonth} 周期 · 自动生成于 ${report.generatedAt} · 报告输出日期 ${report.outputDate}` : "当前筛选条件下没有报告"}
|
||||
actions={report && <>{canExport && <Button variant="outline" onClick={printReport}><Download />导出 PDF</Button>}{canEdit && <Button variant="outline" onClick={() => { updateReport(report.reportId, { status: "编辑中", synced: true }); toast.info("报告保存接口尚未接入"); }}><Save />保存草稿</Button>}{canSend && report.status !== "已发送业务团队" && <Button onClick={() => toast.info("报告发送接口尚未接入")}><Send />发送业务团队</Button>}</>}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="border-b border-border"><CardTitle>报告筛选</CardTitle><CardDescription>{isHistorical ? "历史报告只读查看" : "本页默认仅保留最新月份;历史报告请进入历史报告汇总"}</CardDescription><CardAction><Button variant="link" size="sm" onClick={() => navigate("/operations/report-summary")}>打开历史报告汇总</Button></CardAction></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<FilterSelect label="银行" value={filters.bank} options={unique(reports.map((item) => item.bank))} onChange={(value) => updateFilter("bank", value)} />
|
||||
<FilterSelect label="模型名称" value={filters.name} options={unique(reports.map((item) => item.modelName))} onChange={(value) => updateFilter("name", value)} />
|
||||
<FilterSelect label="模型版本" value={filters.version} options={unique(reports.map((item) => item.version))} onChange={(value) => updateFilter("version", value)} />
|
||||
<FilterSelect label="模型 ID" value={filters.modelId} options={unique(reports.map((item) => item.modelId))} onChange={(value) => updateFilter("modelId", value)} />
|
||||
</div>
|
||||
<FilterSelect label="报告" value={report?.reportId ?? ""} allLabel={pool.length ? "请选择报告" : "无匹配报告"} options={reportOptions} onChange={setSelectedId} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{report && model ? (
|
||||
<>
|
||||
{report.unreadDays >= 5 && report.status !== "已发送业务团队" && <div className="flex items-start gap-3 rounded-xl bg-danger-soft p-4 text-sm text-danger"><TriangleAlert className="mt-0.5 size-5 shrink-0" /><div><b>阅读催办</b><p className="mt-1">该报告已超过 {report.unreadDays} 个工作日未阅读,系统已发送催办短信。</p></div></div>}
|
||||
|
||||
<Card className="operations-report-document">
|
||||
<CardHeader className="border-b border-border"><CardTitle className="flex items-center gap-2"><FileCheck2 className="size-5 text-primary" />{report.bank} · {report.modelName} · {report.version}</CardTitle><CardDescription>信用卡申请评分模型{report.type}</CardDescription><CardAction className="flex items-center gap-2"><ReportTypeBadge type={report.type} /><span className={`rounded-full px-2.5 py-1 text-xs font-medium ${statusClass(report.status)}`}>{report.status}</span>{report.synced && <span className="rounded-full bg-brand-soft px-2.5 py-1 text-xs font-medium text-primary">已同步</span>}</CardAction></CardHeader>
|
||||
<CardContent className="space-y-8 py-8">
|
||||
<section>
|
||||
<h3 className="text-xl font-bold text-foreground">一、申请评分模型{report.type === "诊断报告" ? "诊断" : "监控"}说明</h3>
|
||||
<p className="mt-3 max-w-5xl text-sm leading-7 text-muted-foreground" contentEditable={canEdit} suppressContentEditableWarning>
|
||||
申请评分模型使用行内存量数据验证区分能力与稳定性。风险区分度采用具有完整表现期的核准账户样本,评分分布和稳定性采用本期申请有评分账户进行验证。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h3 className="text-xl font-bold text-foreground">二、{report.modelName}申请评分模型效果验证</h3>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
["验证样本", "—"], ["坏样本", "—"], ["KS", `${model.ks.toFixed(2)}%`], ["PSI", `${model.psi.toFixed(2)}%`],
|
||||
].map(([label, value]) => <div className="rounded-xl bg-muted/50 p-4" key={label}><span className="text-xs text-muted-foreground">{label}</span><strong className="mt-2 block text-xl tabular-nums text-foreground">{value}</strong></div>)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h4 className="text-base font-semibold text-foreground">(一)本期申请评分分布</h4>
|
||||
<ScoreDistributionTable />
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h4 className="text-base font-semibold text-foreground">(二)模型评分排序能力</h4>
|
||||
<SortingComboChart {...SORTING_DISTRIBUTION} />
|
||||
<p className="text-sm leading-7 text-muted-foreground" contentEditable={canEdit} suppressContentEditableWarning>{model.ranking === "相符" ? "随着评分增加,坏账户占比逐渐降低,模型排序能力表现稳定。" : "随着评分增加,坏账户占比未保持单调下降,中段评分区间出现反升,建议结合特征层面进一步排查。"}</p>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-2 gap-6">
|
||||
<Card size="sm"><CardHeader><CardTitle>(三)评分风险区分度验证</CardTitle><CardDescription>KS 分档线 40%,干预线 30%</CardDescription></CardHeader><CardContent><MetricLineChart months={[...MONITOR_MONTHS]} values={ksTrend} name={`${model.modelId} · KS`} thresholds={[{ value: 40, label: "40% 分档线", tone: "danger" }, { value: 30, label: "30% 干预线", tone: "warning" }]} /></CardContent></Card>
|
||||
<Card size="sm"><CardHeader><CardTitle>(四)评分稳定性验证</CardTitle><CardDescription>PSI 使用滚动基准期</CardDescription></CardHeader><CardContent><MetricLineChart months={[...MONITOR_MONTHS]} values={psiTrend} name={`${model.modelId} · PSI`} thresholds={[{ value: 10, label: "10% 关注线", tone: "warning" }, { value: 25, label: "25% 偏移线", tone: "danger" }]} /></CardContent></Card>
|
||||
</section>
|
||||
|
||||
{report.type === "诊断报告" && <section className="space-y-3"><h4 className="text-base font-semibold text-foreground">(五)入模特征诊断</h4><Table><TableHeader><TableRow><TableHead>特征</TableHead><TableHead>IV(当期)</TableHead><TableHead>IV 降幅</TableHead><TableHead>CSI(当期)</TableHead><TableHead>CSI 升幅</TableHead><TableHead>诊断结论</TableHead></TableRow></TableHeader><TableBody>{FEATURE_METRICS.map((feature) => <TableRow key={feature.key}><TableCell><b className="block text-foreground">{feature.name}</b><small className="font-mono text-muted-foreground">{feature.key}</small></TableCell><TableCell>{feature.iv.toFixed(3)}</TableCell><TableCell className="text-danger">-{feature.ivDrop}%</TableCell><TableCell>{feature.csi.toFixed(3)}</TableCell><TableCell className="text-warning">+{feature.csiRise}pp</TableCell><TableCell className="whitespace-normal text-muted-foreground">{feature.csiRise > 2 ? "分布迁移明显,建议核查上游数据口径" : "分布基本稳定"}</TableCell></TableRow>)}</TableBody></Table></section>}
|
||||
|
||||
<section className="rounded-xl bg-brand-soft p-5">
|
||||
<h3 className="text-base font-semibold text-primary">三、总结</h3>
|
||||
<p className="mt-2 text-sm leading-7 text-primary/80" contentEditable={canEdit} suppressContentEditableWarning>
|
||||
本次模型{report.type === "诊断报告" ? "诊断" : "监控"}显示:模型 KS 为 {model.ks.toFixed(2)}%,PSI 为 {model.psi.toFixed(2)}%;{model.ranking === "相符" ? "排序性保持稳定。" : "排序性存在反转,建议启动模型微调或重构评估。"}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<p className="rounded-xl border border-border p-4 text-xs leading-5 text-muted-foreground">报告中的指标由平台程序取数计算,大模型仅负责文字表述与归因;正文不展示监控结果等级。</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : <Card><CardContent className="py-16 text-center text-sm text-muted-foreground">当前筛选条件下没有报告</CardContent></Card>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ArrowUpDown, ChevronLeft, ChevronRight, Download, FileClock, RotateCcw } from "lucide-react";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { FilterSelect, MultiSelectFilter, OperationsPageHeader } from "./OperationsUi";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
|
||||
import { exportRowsToExcel } from "~/lib/exportExcel";
|
||||
import { type MonitoringReport, type ReportStatus } from "./reportData";
|
||||
import { useReportStore } from "./reportStore";
|
||||
|
||||
type Filters = {
|
||||
bank: string[];
|
||||
name: string[];
|
||||
version: string[];
|
||||
modelId: string[];
|
||||
month: string[];
|
||||
type: string[];
|
||||
status: string[];
|
||||
};
|
||||
type SortKey = "bank" | "modelId" | "month" | "generatedAt";
|
||||
|
||||
function emptyFilters(): Filters {
|
||||
return { bank: [], name: [], version: [], modelId: [], month: [], type: [], status: [] };
|
||||
}
|
||||
|
||||
function unique(values: string[]): string[] {
|
||||
return [...new Set(values)].sort((left, right) => right.localeCompare(left, "zh-CN"));
|
||||
}
|
||||
|
||||
function parseValues(params: URLSearchParams, key: string): string[] {
|
||||
return [...new Set(params.getAll(key).flatMap((value) => value.split(",")).filter(Boolean))];
|
||||
}
|
||||
|
||||
function statusClass(status: ReportStatus): string {
|
||||
if (status === "已发送业务团队") return "bg-success-soft text-success-strong";
|
||||
if (status === "编辑中") return "bg-brand-soft text-primary";
|
||||
return "bg-warning-soft text-warning";
|
||||
}
|
||||
|
||||
function exportReports(rows: MonitoringReport[]) {
|
||||
const header = ["银行", "模型名称", "版本", "模型ID", "月份", "报告类型", "生成时间", "输出日期", "状态"];
|
||||
return exportRowsToExcel({
|
||||
fileName: "模型历史报告汇总",
|
||||
sheetName: "历史报告汇总",
|
||||
headers: header,
|
||||
rows: rows.map((row) => [row.bank, row.modelName, row.version, row.modelId, row.monitorMonth, row.type, row.generatedAt, row.outputDate, row.status]),
|
||||
});
|
||||
}
|
||||
|
||||
function matches(values: string[], value: string): boolean {
|
||||
return !values.length || values.includes(value);
|
||||
}
|
||||
|
||||
function SortableHead({ label, sortKey, currentKey, onSort }: { label: string; sortKey: SortKey; currentKey: SortKey; onSort: (key: SortKey) => void }) {
|
||||
return <TableHead><button className="inline-flex items-center gap-1.5 font-medium hover:text-primary" type="button" onClick={() => onSort(sortKey)}>{label}<ArrowUpDown className={`size-3.5 ${currentKey === sortKey ? "text-primary" : "text-muted-foreground"}`} /></button></TableHead>;
|
||||
}
|
||||
|
||||
export default function ReportSummaryPage() {
|
||||
const navigate = useNavigate();
|
||||
const reports = useReportStore((state) => state.reports);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [filters, setFilters] = useState<Filters>(() => ({
|
||||
bank: parseValues(searchParams, "bank"),
|
||||
name: parseValues(searchParams, "name"),
|
||||
version: parseValues(searchParams, "version"),
|
||||
modelId: parseValues(searchParams, "modelId"),
|
||||
month: parseValues(searchParams, "month"),
|
||||
type: parseValues(searchParams, "type"),
|
||||
status: parseValues(searchParams, "status"),
|
||||
}));
|
||||
const [sort, setSort] = useState<{ key: SortKey; direction: "asc" | "desc" }>({ key: "month", direction: "desc" });
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
(Object.keys(filters) as Array<keyof Filters>).forEach((key) => filters[key].forEach((value) => params.append(key, value)));
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [filters, setSearchParams]);
|
||||
|
||||
const filteredRows = useMemo(() => reports.filter((report) => (
|
||||
matches(filters.bank, report.bank)
|
||||
&& matches(filters.name, report.modelName)
|
||||
&& matches(filters.version, report.version)
|
||||
&& matches(filters.modelId, report.modelId)
|
||||
&& matches(filters.month, report.monitorMonth)
|
||||
&& matches(filters.type, report.type)
|
||||
&& matches(filters.status, report.status)
|
||||
)), [filters, reports]);
|
||||
|
||||
const rows = useMemo(() => [...filteredRows].sort((left, right) => {
|
||||
const pairs: Record<SortKey, [string, string]> = {
|
||||
bank: [left.bank, right.bank],
|
||||
modelId: [left.modelId, right.modelId],
|
||||
month: [left.monitorMonth, right.monitorMonth],
|
||||
generatedAt: [left.generatedAt, right.generatedAt],
|
||||
};
|
||||
const [a, b] = pairs[sort.key];
|
||||
const result = a.localeCompare(b, "zh-CN");
|
||||
return sort.direction === "asc" ? result : -result;
|
||||
}), [filteredRows, sort]);
|
||||
|
||||
useEffect(() => setPage(1), [filters, pageSize, sort]);
|
||||
const pageCount = Math.max(1, Math.ceil(rows.length / pageSize));
|
||||
const safePage = Math.min(page, pageCount);
|
||||
const pageRows = rows.slice((safePage - 1) * pageSize, safePage * pageSize);
|
||||
const update = <K extends keyof Filters>(key: K, value: string[]) => setFilters((current) => ({ ...current, [key]: value }));
|
||||
const changeSort = (key: SortKey) => setSort((current) => current.key === key ? { key, direction: current.direction === "asc" ? "desc" : "asc" } : { key, direction: "asc" });
|
||||
|
||||
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="按银行、模型、版本、模型 ID 与月份任意组合多选,查看最新和历史报告。" actions={<Button onClick={() => void exportReports(rows)}><Download />导出 Excel</Button>} />
|
||||
|
||||
<Card className="overflow-visible">
|
||||
<CardHeader className="border-b border-border"><CardTitle className="flex items-center gap-2"><FileClock className="size-5 text-primary" />全部报告</CardTitle><CardDescription>共 {rows.length} 份 · 当前第 {safePage} / {pageCount} 页</CardDescription><CardAction className="flex gap-2"><Button variant="outline" size="sm" onClick={() => setFilters(emptyFilters())}><RotateCcw />重置筛选</Button><Button size="sm" onClick={() => void exportReports(rows)}><Download />导出 Excel</Button></CardAction></CardHeader>
|
||||
<CardContent className="grid grid-cols-7 gap-3">
|
||||
<MultiSelectFilter label="银行" values={filters.bank} options={unique(reports.map((item) => item.bank))} onChange={(value) => update("bank", value)} />
|
||||
<MultiSelectFilter label="模型名称" values={filters.name} options={unique(reports.map((item) => item.modelName))} onChange={(value) => update("name", value)} />
|
||||
<MultiSelectFilter label="模型版本" values={filters.version} options={unique(reports.map((item) => item.version))} onChange={(value) => update("version", value)} />
|
||||
<MultiSelectFilter label="模型 ID" values={filters.modelId} options={unique(reports.map((item) => item.modelId))} onChange={(value) => update("modelId", value)} />
|
||||
<MultiSelectFilter label="月份" values={filters.month} options={unique(reports.map((item) => item.monitorMonth))} onChange={(value) => update("month", value)} />
|
||||
<MultiSelectFilter label="报告类型" values={filters.type} options={["监控报告", "诊断报告"]} onChange={(value) => update("type", value)} />
|
||||
<MultiSelectFilter label="状态" values={filters.status} options={["待模型团队阅读", "编辑中", "已发送业务团队"]} onChange={(value) => update("status", value)} />
|
||||
</CardContent>
|
||||
<CardContent className="max-h-[40rem] overflow-auto px-0 pt-0">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-card"><TableRow><SortableHead label="银行" sortKey="bank" currentKey={sort.key} onSort={changeSort} /><TableHead>模型名称</TableHead><TableHead>版本</TableHead><SortableHead label="模型ID" sortKey="modelId" currentKey={sort.key} onSort={changeSort} /><SortableHead label="月份" sortKey="month" currentKey={sort.key} onSort={changeSort} /><TableHead>报告类型</TableHead><SortableHead label="生成时间" sortKey="generatedAt" currentKey={sort.key} onSort={changeSort} /><TableHead>输出日期</TableHead><TableHead>状态</TableHead><TableHead className="text-right">操作</TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{pageRows.length ? pageRows.map((report) => <TableRow key={report.reportId}><TableCell className="font-medium text-foreground">{report.bank}</TableCell><TableCell>{report.modelName}</TableCell><TableCell>{report.version}</TableCell><TableCell className="font-mono text-xs">{report.modelId}</TableCell><TableCell className="font-mono text-xs">{report.monitorMonth}</TableCell><TableCell><span className={report.type === "监控报告" ? "rounded-full bg-success-soft px-2.5 py-1 text-xs font-medium text-success-strong" : "rounded-full bg-warning-soft px-2.5 py-1 text-xs font-medium text-warning"}>{report.type}</span></TableCell><TableCell className="font-mono text-xs">{report.generatedAt}</TableCell><TableCell>{report.outputDate}</TableCell><TableCell><span className={`rounded-full px-2.5 py-1 text-xs font-medium ${statusClass(report.status)}`}>{report.status}</span>{report.unreadDays >= 5 && report.status !== "已发送业务团队" && <span className="ml-2 rounded-full bg-danger-soft px-2.5 py-1 text-xs font-medium text-danger">超 {report.unreadDays} 日</span>}{report.synced && <span className="ml-2 rounded-full bg-brand-soft px-2.5 py-1 text-xs font-medium text-primary">已同步</span>}</TableCell><TableCell className="text-right"><Button variant="link" size="sm" onClick={() => navigate(`/operations/reports?report=${report.reportId}`)}>打开</Button><Button variant="link" size="sm" onClick={() => toast.info("PDF 排版与下载将在报告服务接口接入后启用")}>导出 PDF</Button></TableCell></TableRow>) : <TableRow><TableCell colSpan={10} className="h-32 text-center text-muted-foreground">当前筛选条件下没有报告</TableCell></TableRow>}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
<CardContent className="flex items-center justify-between border-t border-border py-3"><div className="flex items-center gap-2 text-xs text-muted-foreground"><span>每页</span><FilterSelect className="w-24" label="" value={String(pageSize)} allLabel="请选择" options={["10", "20", "50"]} onChange={(value) => setPageSize(Number(value))} /><span>条</span></div><div className="flex items-center gap-2"><Button variant="outline" size="icon-sm" aria-label="上一页" disabled={safePage <= 1} onClick={() => setPage((value) => Math.max(1, value - 1))}><ChevronLeft /></Button><span className="min-w-20 text-center text-xs text-muted-foreground">{safePage} / {pageCount}</span><Button variant="outline" size="icon-sm" aria-label="下一页" disabled={safePage >= pageCount} onClick={() => setPage((value) => Math.min(pageCount, value + 1))}><ChevronRight /></Button></div></CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useState } from "react";
|
||||
import { Database, Download, History, RefreshCw, Save } 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 { FilterSelect, OperationsPageHeader } from "./OperationsUi";
|
||||
import { BANK_REPORT_CONFIG, TEMPLATE_VERSIONS } from "./governanceData";
|
||||
|
||||
function nextGeneration(frequency: string, day: number): string {
|
||||
const formattedDay = String(day).padStart(2, "0");
|
||||
if (frequency === "月度") return `2026-09-${formattedDay}`;
|
||||
if (frequency === "季度") return `2026-10-${formattedDay}`;
|
||||
return `2027-01-${formattedDay}`;
|
||||
}
|
||||
|
||||
export default function SystemConfigPage() {
|
||||
const [readDay, setReadDay] = useState("");
|
||||
const [readPeriod, setReadPeriod] = useState("");
|
||||
const [templates] = useState(TEMPLATE_VERSIONS);
|
||||
const [selectedBank, setSelectedBank] = useState("");
|
||||
const [bankConfigs] = useState(BANK_REPORT_CONFIG);
|
||||
const currentBankConfig = selectedBank ? bankConfigs[selectedBank] : undefined;
|
||||
const notificationRows: string[][] = [];
|
||||
|
||||
const publishTemplate = () => {
|
||||
toast.info("报告模板接口尚未接入");
|
||||
};
|
||||
|
||||
const updateBankConfig = (updates: Partial<{ frequency: string; day: number }>) => {
|
||||
void updates;
|
||||
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="维护指标读取、报告模板、通知催办和银行报告周期。" actions={<Button onClick={() => toast.info("系统配置接口尚未接入")}><Save />保存配置</Button>} />
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="flex items-center gap-2"><Database className="size-5 text-primary" />指标读取配置</CardTitle><CardDescription>从共享数据库读取模型平台计算结果</CardDescription></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3"><FilterSelect label="读取日(每月)" value={readDay} allLabel="请选择" options={["1 日", "15 日", "20 日"]} onChange={setReadDay} /><FilterSelect label="读取周期" value={readPeriod} allLabel="请选择" options={["上一周期(月)", "上一周期(季)"]} onChange={setReadPeriod} /></div>
|
||||
<Button className="w-full" variant="outline" onClick={() => toast.info("指标同步接口尚未接入")}><RefreshCw />立即重新读取上一周期指标</Button>
|
||||
<p className="rounded-xl bg-muted/50 p-4 text-xs leading-5 text-muted-foreground">读取排序性、KS、PSI、IV、CSI。本平台不重算指标,只读取模型平台计算结果;共享表名和字段映射待接口设计阶段确定。</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="flex items-center gap-2"><History className="size-5 text-primary" />报告模板版本管理</CardTitle><CardDescription>监控报告与诊断报告共用版本体系</CardDescription><CardAction><Button size="sm" onClick={publishTemplate}>发布新版本</Button></CardAction></CardHeader>
|
||||
<CardContent className="space-y-3">{templates.length ? templates.map((template) => <div className="flex items-center gap-3 rounded-xl border border-border p-4" key={template.version}><b className="text-sm text-foreground">{template.version}</b><span className="min-w-0 flex-1"><small className="block truncate text-xs text-muted-foreground">{template.note}</small><small className="text-3xs text-muted-foreground">{template.author} · {template.createdAt}</small></span>{template.current ? <span className="rounded-full bg-success-soft px-2.5 py-1 text-xs text-success-strong">当前生效</span> : <Button variant="outline" size="xs" onClick={() => toast.info("报告模板接口尚未接入")}>一键回滚</Button>}</div>) : <div className="py-10 text-center text-sm text-muted-foreground">暂无报告模板版本数据</div>}</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="border-b border-border"><CardTitle>通知与催办</CardTitle><CardDescription>邮件通道已取消</CardDescription><CardAction><Button variant="outline" size="xs" onClick={() => void exportRowsToExcel({ fileName: "通知与催办配置", sheetName: "通知配置", headers: ["配置项", "配置值"], rows: notificationRows })}><Download />导出 Excel</Button></CardAction></CardHeader>
|
||||
<CardContent className="px-0"><Table><TableBody>{notificationRows.map(([item, value]) => <TableRow key={item}><TableCell className="w-52 font-medium text-foreground">{item}</TableCell><TableCell>{value}</TableCell></TableRow>)}</TableBody></Table></CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="border-b border-border"><CardTitle>报告时间维度与输出日期</CardTitle><CardDescription>按银行单独配置,不强制统一为每月 15 日</CardDescription><CardAction className="flex items-end gap-2"><FilterSelect className="w-44" label="选择银行" value={selectedBank} allLabel="暂无银行数据" options={Object.keys(bankConfigs)} onChange={setSelectedBank} /><Button variant="outline" size="xs" onClick={() => void exportRowsToExcel({ fileName: "报告周期配置", sheetName: "报告周期", headers: ["银行", "报告时间维度", "输出日期", "下次生成"], rows: Object.entries(bankConfigs).map(([bank, config]) => [bank, config.frequency, `每月 ${config.day} 日`, nextGeneration(config.frequency, config.day)]) })}><Download />导出 Excel</Button></CardAction></CardHeader>
|
||||
<CardContent className="grid grid-cols-3 gap-3"><FilterSelect label="报告时间维度" value={currentBankConfig?.frequency ?? ""} allLabel="暂无配置" options={["月度", "季度", "半年度", "年度"]} disabled={!currentBankConfig} onChange={(frequency) => updateBankConfig({ frequency })} /><label className="flex flex-col gap-1.5"><span className="text-xs font-medium text-ink-caption">报告输出日期(每月第几日)</span><Input disabled={!currentBankConfig} type="number" min="1" max="28" value={currentBankConfig?.day ?? ""} onChange={(event) => updateBankConfig({ day: Number(event.target.value) })} /></label><label className="flex flex-col gap-1.5"><span className="text-xs font-medium text-ink-caption">下次生成</span><Input disabled value={currentBankConfig ? nextGeneration(currentBankConfig.frequency, currentBankConfig.day) : "—"} /></label></CardContent>
|
||||
<CardContent className="px-0 pt-0"><Table><TableHeader><TableRow><TableHead>银行</TableHead><TableHead>报告时间维度</TableHead><TableHead>输出日期</TableHead><TableHead>下次生成</TableHead></TableRow></TableHeader><TableBody>{Object.entries(bankConfigs).length ? Object.entries(bankConfigs).map(([bank, config]) => <TableRow data-state={bank === selectedBank ? "selected" : undefined} key={bank}><TableCell className="font-medium text-foreground">{bank}</TableCell><TableCell>{config.frequency}</TableCell><TableCell>每月 {config.day} 日</TableCell><TableCell>{nextGeneration(config.frequency, config.day)}</TableCell></TableRow>) : <TableRow><TableCell colSpan={4} className="h-28 text-center text-muted-foreground">暂无银行报告周期数据</TableCell></TableRow>}</TableBody></Table></CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Download, FileDown, LogIn, Search, Send } from "lucide-react";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, 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 { FilterSelect, OperationsPageHeader } from "./OperationsUi";
|
||||
import { USAGE_RECORDS, type UsageRecord } from "./workflowData";
|
||||
|
||||
function exportUsage(rows: UsageRecord[]) {
|
||||
const header = ["姓名", "所属", "登录次数", "提交需求", "阅读报告", "下载报告", "最近登录"];
|
||||
return exportRowsToExcel({ fileName: "平台使用统计", sheetName: "使用统计", headers: header, rows: rows.map((row) => [row.person, row.team, row.logins, row.requests, row.reportsRead, row.reportsDownloaded, row.lastLoginAt]) });
|
||||
}
|
||||
|
||||
export default function UsageStatsPage() {
|
||||
const [team, setTeam] = useState("");
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const rows = useMemo(() => USAGE_RECORDS.filter((record) => (
|
||||
record.team !== "管理员"
|
||||
&& (!team || record.team === team)
|
||||
&& (!keyword.trim() || record.person.includes(keyword.trim()))
|
||||
)), [keyword, team]);
|
||||
const business = USAGE_RECORDS.filter((record) => record.team === "业务团队");
|
||||
const model = USAGE_RECORDS.filter((record) => record.team === "模型团队");
|
||||
const sum = (records: UsageRecord[], key: "logins" | "requests" | "reportsRead" | "reportsDownloaded") => records.reduce((total, record) => total + record[key], 0);
|
||||
const metrics = [
|
||||
{ label: "业务团队登录", value: sum(business, "logins"), detail: business.length ? `${business.length} 人 · 人均 ${(sum(business, "logins") / business.length).toFixed(1)} 次` : "暂无数据", Icon: LogIn },
|
||||
{ label: "模型团队登录", value: sum(model, "logins"), detail: model.length ? `${model.length} 人 · 人均 ${(sum(model, "logins") / model.length).toFixed(1)} 次` : "暂无数据", Icon: LogIn },
|
||||
{ label: "提交需求", value: sum(USAGE_RECORDS, "requests"), detail: "累计业务团队发起", Icon: Send },
|
||||
{ label: "报告下载", value: sum(USAGE_RECORDS, "reportsDownloaded"), detail: "累计两方合计", Icon: FileDown },
|
||||
];
|
||||
|
||||
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="查看业务团队与模型团队的平台使用情况,只做人员维度汇总,不展示操作内容明细。"
|
||||
actions={<Button onClick={() => void exportUsage(rows)}><Download />导出 Excel</Button>}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{metrics.map(({ label, value, detail, Icon }) => (
|
||||
<Card key={label} size="sm"><CardContent className="flex items-center gap-3"><span className="grid size-10 place-items-center rounded-xl bg-brand-soft text-primary"><Icon className="size-5" /></span><span><strong className="block text-xl font-bold tabular-nums text-foreground">{value}</strong><b className="block text-sm text-foreground">{label}</b><small className="text-xs text-muted-foreground">{detail}</small></span></CardContent></Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="border-b border-border"><CardTitle>人员使用情况</CardTitle><CardDescription>共 {rows.length} 人</CardDescription></CardHeader>
|
||||
<CardContent className="flex items-end gap-3">
|
||||
<label className="relative block w-72"><Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" /><Input className="pl-9" placeholder="搜索姓名" value={keyword} onChange={(event) => setKeyword(event.target.value)} /></label>
|
||||
<FilterSelect className="w-48" label="所属团队" value={team} options={["业务团队", "模型团队"]} onChange={setTeam} />
|
||||
</CardContent>
|
||||
<CardContent className="px-0 pt-0">
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>姓名</TableHead><TableHead>所属</TableHead><TableHead>登录次数</TableHead><TableHead>提交需求</TableHead><TableHead>阅读报告</TableHead><TableHead>下载报告</TableHead><TableHead>最近登录</TableHead><TableHead>活跃度</TableHead></TableRow></TableHeader>
|
||||
<TableBody>{rows.length ? rows.map((record) => {
|
||||
const activity = record.logins >= 40 ? ["高", "bg-success-soft text-success-strong"] : record.logins >= 15 ? ["中", "bg-brand-soft text-primary"] : ["低", "bg-warning-soft text-warning"];
|
||||
return <TableRow key={`${record.team}-${record.person}`}><TableCell className="font-medium text-foreground">{record.person}</TableCell><TableCell>{record.team}</TableCell><TableCell className="tabular-nums">{record.logins}</TableCell><TableCell className="tabular-nums">{record.requests}</TableCell><TableCell className="tabular-nums">{record.reportsRead}</TableCell><TableCell className="tabular-nums">{record.reportsDownloaded}</TableCell><TableCell className="font-mono text-xs">{record.lastLoginAt}</TableCell><TableCell><span className={`rounded-full px-2.5 py-1 text-xs font-medium ${activity[1]}`}>{activity[0]}</span></TableCell></TableRow>;
|
||||
}) : <TableRow><TableCell colSpan={8} className="h-32 text-center text-muted-foreground">暂无使用统计数据</TableCell></TableRow>}</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { ArrowRight, Bell, Check, ChevronDown, Download, FileUp, Plus, RotateCcw } 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 { 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 { FilterSelect, OperationsPageHeader } from "./OperationsUi";
|
||||
import { MODEL_CATEGORIES, categoryName, type ModelCategoryId } from "./modelData";
|
||||
import { WORKFLOWS, WORKFLOW_STAGES, type WorkflowFile, type WorkflowInstance } from "./workflowData";
|
||||
import { useCanOperationsAction, useOperationsRole } from "./operationsRole";
|
||||
|
||||
type Filters = { bank: string; category: string; year: string; status: string; reuse: string };
|
||||
const EMPTY_FILTERS: Filters = { bank: "", category: "", year: "", status: "", reuse: "" };
|
||||
|
||||
function unique(values: string[]): string[] {
|
||||
return [...new Set(values)].sort((left, right) => left.localeCompare(right, "zh-CN"));
|
||||
}
|
||||
|
||||
function statusOf(workflow: WorkflowInstance): "进行中" | "已完结" {
|
||||
return workflow.currentStage > WORKFLOW_STAGES.length ? "已完结" : "进行中";
|
||||
}
|
||||
|
||||
export default function WorkflowPage() {
|
||||
const operationsRole = useOperationsRole();
|
||||
const canCreate = useCanOperationsAction("workflow:create");
|
||||
const canFeedback = useCanOperationsAction("workflow:feedback");
|
||||
const canSubmitMaterial = useCanOperationsAction("workflow:submit-material");
|
||||
const canAdvance = useCanOperationsAction("workflow:advance");
|
||||
const canOnline = useCanOperationsAction("workflow:online");
|
||||
const canBusinessConfirm = useCanOperationsAction("workflow:business-confirm");
|
||||
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
|
||||
const [selectedId, setSelectedId] = useState(WORKFLOWS[0]?.workflowId ?? "");
|
||||
const [statisticsYear, setStatisticsYear] = useState("2026");
|
||||
const [openStages, setOpenStages] = useState<Set<number>>(new Set([WORKFLOWS[0]?.currentStage ?? 1]));
|
||||
const [newRequestOpen, setNewRequestOpen] = useState(false);
|
||||
const [newBank, setNewBank] = useState("");
|
||||
const [newCategory, setNewCategory] = useState("std");
|
||||
const [newReuse, setNewReuse] = useState("独立开发");
|
||||
const [localFiles, setLocalFiles] = useState<Record<string, WorkflowFile[]>>({});
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const uploadTargetRef = useRef<{ workflowId: string; stage: number } | null>(null);
|
||||
const filtered = useMemo(() => WORKFLOWS.filter((workflow) => (
|
||||
(!filters.bank || workflow.bank === filters.bank)
|
||||
&& (!filters.category || workflow.category === filters.category)
|
||||
&& (!filters.year || workflow.initiatedAt.startsWith(filters.year))
|
||||
&& (!filters.status || statusOf(workflow) === filters.status)
|
||||
&& (!filters.reuse || (workflow.reusedModel ? "复用通用模型" : "独立开发") === filters.reuse)
|
||||
)), [filters]);
|
||||
const workflow = filtered.find((item) => item.workflowId === selectedId) ?? filtered[0] ?? null;
|
||||
const statisticsPool = WORKFLOWS.filter((item) => statisticsYear === "全部" || item.initiatedAt.startsWith(statisticsYear));
|
||||
|
||||
const update = <K extends keyof Filters>(key: K, value: Filters[K]) => {
|
||||
setFilters((current) => ({ ...current, [key]: value }));
|
||||
setSelectedId("");
|
||||
};
|
||||
|
||||
const toggleStage = (stage: number) => {
|
||||
setOpenStages((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(stage)) next.delete(stage);
|
||||
else next.add(stage);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const chooseWorkflowFile = (workflowId: string, stage: number) => {
|
||||
uploadTargetRef.current = { workflowId, stage };
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const addWorkflowFile = (file: File | undefined) => {
|
||||
const target = uploadTargetRef.current;
|
||||
if (!file || !target) return;
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
toast.error("文件不能超过 50 MB");
|
||||
return;
|
||||
}
|
||||
const key = `${target.workflowId}:${target.stage}`;
|
||||
const uploaded: WorkflowFile = {
|
||||
name: file.name,
|
||||
uploadedBy: operationsRole === "business" ? "业务团队(当前用户)" : "模型团队(当前用户)",
|
||||
uploadedAt: new Date().toLocaleString("zh-CN", { hour12: false }),
|
||||
confirmed: target.stage === 3,
|
||||
};
|
||||
setLocalFiles((current) => ({ ...current, [key]: [...(current[key] ?? []), uploaded] }));
|
||||
toast.info("材料已暂存当前页面,文件上传接口尚未接入");
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="h-full overflow-auto bg-bg p-6">
|
||||
<div className="flex w-full flex-col gap-6 pb-8">
|
||||
<input ref={fileInputRef} className="hidden" type="file" accept=".doc,.docx,.xls,.xlsx,.pdf,.ppt,.pptx,.zip" onChange={(event) => { addWorkflowFile(event.target.files?.[0]); event.target.value = ""; }} />
|
||||
<OperationsPageHeader
|
||||
title="全流程进度"
|
||||
description="展示部署上线前七阶段流程、材料留痕与关键节点催办;开发和上线操作仍在模型平台完成。"
|
||||
actions={canCreate ? <Button onClick={() => setNewRequestOpen(true)}><Plus />发起需求</Button> : undefined}
|
||||
/>
|
||||
|
||||
<div className="rounded-xl bg-brand-soft p-4 text-sm leading-6 text-primary">
|
||||
本平台负责流程展示、材料留痕、关键节点催办与审计;模型开发、测试执行和部署操作由模型平台完成。
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="border-b border-border"><CardTitle>历史流程统计</CardTitle><CardDescription>各环节累计处理量,作为工作量统计口径</CardDescription><CardAction className="flex items-end gap-2"><FilterSelect className="w-36" label="统计范围" value={statisticsYear} allLabel="请选择" options={["2026", "2025", "全部"]} onChange={setStatisticsYear} /><Button variant="outline" size="sm" onClick={() => void exportRowsToExcel({ fileName: `历史流程统计_${statisticsYear}`, sheetName: "流程统计", headers: ["统计口径", ...WORKFLOW_STAGES.map((stage) => `${stage.stage}.${stage.title}`)], rows: [["涉及银行数", ...WORKFLOW_STAGES.map((stage) => new Set(statisticsPool.filter((item) => item.currentStage >= stage.stage).map((item) => item.bank)).size)], ["需求 / 流程数", ...WORKFLOW_STAGES.map((stage) => statisticsPool.filter((item) => item.currentStage >= stage.stage).length)], ["已完成", ...WORKFLOW_STAGES.map((stage) => statisticsPool.filter((item) => item.currentStage > stage.stage).length)]] })}><Download />导出 Excel</Button></CardAction></CardHeader>
|
||||
<CardContent className="px-0">
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>统计口径</TableHead>{WORKFLOW_STAGES.map((stage) => <TableHead className="text-center" key={stage.stage}>{stage.stage}. {stage.title}</TableHead>)}</TableRow></TableHeader>
|
||||
<TableBody>
|
||||
<TableRow><TableCell className="font-medium">涉及银行数</TableCell>{WORKFLOW_STAGES.map((stage) => <TableCell className="text-center font-semibold tabular-nums" key={stage.stage}>{new Set(statisticsPool.filter((item) => item.currentStage >= stage.stage).map((item) => item.bank)).size}</TableCell>)}</TableRow>
|
||||
<TableRow><TableCell className="font-medium">需求 / 流程数</TableCell>{WORKFLOW_STAGES.map((stage) => <TableCell className="text-center tabular-nums" key={stage.stage}>{statisticsPool.filter((item) => item.currentStage >= stage.stage).length}</TableCell>)}</TableRow>
|
||||
<TableRow><TableCell className="font-medium">已完成</TableCell>{WORKFLOW_STAGES.map((stage) => <TableCell className="text-center tabular-nums" key={stage.stage}>{statisticsPool.filter((item) => item.currentStage > stage.stage).length}</TableCell>)}</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="border-b border-border"><CardTitle>流程实例</CardTitle><CardDescription>共 {filtered.length} 个流程</CardDescription><CardAction><Button variant="outline" size="sm" onClick={() => setFilters(EMPTY_FILTERS)}><RotateCcw />重置筛选</Button></CardAction></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
<FilterSelect label="银行" value={filters.bank} options={unique(WORKFLOWS.map((item) => item.bank))} onChange={(value) => update("bank", value)} />
|
||||
<FilterSelect label="模型大类" value={filters.category} options={MODEL_CATEGORIES.map((item) => ({ label: item.name, value: item.id }))} onChange={(value) => update("category", value)} />
|
||||
<FilterSelect label="发起年份" value={filters.year} options={unique(WORKFLOWS.map((item) => item.initiatedAt.slice(0, 4)))} onChange={(value) => update("year", value)} />
|
||||
<FilterSelect label="流程状态" value={filters.status} options={["进行中", "已完结"]} onChange={(value) => update("status", value)} />
|
||||
<FilterSelect label="是否复用通用" value={filters.reuse} options={["复用通用模型", "独立开发"]} onChange={(value) => update("reuse", value)} />
|
||||
</div>
|
||||
<FilterSelect label="查看流程" value={workflow?.workflowId ?? ""} allLabel={filtered.length ? "请选择流程" : "无匹配流程"} options={filtered.map((item) => ({ label: `${item.title}${statusOf(item) === "已完结" ? "(已完结)" : ""}`, value: item.workflowId }))} onChange={(value) => { setSelectedId(value); const selected = filtered.find((item) => item.workflowId === value); if (selected) setOpenStages(new Set([Math.min(selected.currentStage, 7)])); }} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{workflow ? (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader><CardTitle>{workflow.title}</CardTitle><CardDescription>{workflow.initiatedBy} · {workflow.initiatedAt}</CardDescription><CardAction>{statusOf(workflow) === "已完结" ? <span className="rounded-full bg-success-soft px-2.5 py-1 text-xs font-medium text-success-strong">已完结</span> : <span className="rounded-full bg-brand-soft px-2.5 py-1 text-xs font-medium text-primary">第 {workflow.currentStage} 步进行中</span>}</CardAction></CardHeader>
|
||||
<CardContent className="grid grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] gap-6">
|
||||
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-3 text-sm"><dt className="text-muted-foreground">银行 / 模型</dt><dd className="font-medium">{workflow.bank} · {categoryName(workflow.category)} · <span className="font-mono text-xs">{workflow.modelId}</span></dd><dt className="text-muted-foreground">模型来源</dt><dd>{workflow.reusedModel ? <span className="rounded-full bg-brand-soft px-2.5 py-1 text-xs font-medium text-primary">复用 · {workflow.reusedModel}</span> : <span className="rounded-full bg-muted px-2.5 py-1 text-xs">独立开发</span>}</dd><dt className="text-muted-foreground">当前环节</dt><dd>{statusOf(workflow) === "已完结" ? `已于 ${workflow.completedAt} 完结` : `${workflow.currentStage}. ${WORKFLOW_STAGES[workflow.currentStage - 1]?.title}`}{workflow.stalledDays ? <span className="ml-2 rounded-full bg-warning-soft px-2.5 py-1 text-xs text-warning">停滞 {workflow.stalledDays} 天</span> : null}</dd></dl>
|
||||
<div><div className="mb-2 flex items-center justify-between text-sm"><span className="text-muted-foreground">总体进度</span><b className="tabular-nums text-primary">{workflow.currentStage > 7 ? 100 : Math.round((workflow.currentStage - 1) / 7 * 100)}%</b></div><div className="grid grid-cols-7 gap-2">{WORKFLOW_STAGES.map((stage) => <div key={stage.stage}><span className={`block h-2 rounded-full ${stage.stage < workflow.currentStage || workflow.currentStage > 7 ? "bg-success" : stage.stage === workflow.currentStage ? "bg-primary" : "bg-muted"}`} /><small className={`mt-2 block text-center text-3xs ${stage.stage === workflow.currentStage ? "font-semibold text-primary" : "text-muted-foreground"}`}>{stage.stage}. {stage.title}</small></div>)}</div></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-3">
|
||||
{WORKFLOW_STAGES.map((stage) => {
|
||||
const done = workflow.currentStage > stage.stage;
|
||||
const current = workflow.currentStage === stage.stage;
|
||||
const files = [...(workflow.files[stage.stage] ?? []), ...(localFiles[`${workflow.workflowId}:${stage.stage}`] ?? [])];
|
||||
const open = openStages.has(stage.stage);
|
||||
const canUpload = canSubmitMaterial || (canBusinessConfirm && stage.stage === 4);
|
||||
return (
|
||||
<Card key={stage.stage} size="sm">
|
||||
<button className="flex w-full cursor-pointer items-center gap-3 px-4 text-left" type="button" onClick={() => toggleStage(stage.stage)}>
|
||||
<span className={`grid size-8 shrink-0 place-items-center rounded-full text-xs font-semibold ${done ? "bg-success text-white" : current ? "bg-primary text-white" : "bg-muted text-muted-foreground"}`}>{done ? <Check className="size-4" /> : stage.stage}</span>
|
||||
<span className="flex-1"><b className="block text-sm text-foreground">{stage.title}</b><small className="text-xs text-muted-foreground">{files.length} 份材料{current && workflow.deadline ? ` · 最晚反馈 ${workflow.deadline}` : ""}</small></span>
|
||||
{current && <span className="rounded-full bg-brand-soft px-2.5 py-1 text-xs font-medium text-primary">进行中</span>}{done && <span className="rounded-full bg-success-soft px-2.5 py-1 text-xs font-medium text-success-strong">已完成</span>}{!done && !current && <span className="rounded-full bg-muted px-2.5 py-1 text-xs text-muted-foreground">未开始</span>}
|
||||
<ChevronDown className={`size-4 text-muted-foreground transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{open && <CardContent className="space-y-4 border-t border-border pt-4"><div className="grid grid-cols-2 gap-4"><div className="rounded-xl bg-muted/50 p-4"><b className="text-sm text-foreground">业务团队</b><p className="mt-1 text-xs leading-5 text-muted-foreground">{stage.businessDuty}</p></div><div className="rounded-xl bg-muted/50 p-4"><b className="text-sm text-foreground">模型团队</b><p className="mt-1 text-xs leading-5 text-muted-foreground">{stage.modelDuty}</p></div></div>{files.length ? <Table><TableHeader><TableRow><TableHead>材料名称</TableHead><TableHead>上传人</TableHead><TableHead>上传时间</TableHead><TableHead>确认状态</TableHead><TableHead className="text-right">操作</TableHead></TableRow></TableHeader><TableBody>{files.map((file) => <TableRow key={`${file.name}-${file.uploadedAt}`}><TableCell className="font-medium text-foreground">{file.name}</TableCell><TableCell>{file.uploadedBy}</TableCell><TableCell>{file.uploadedAt}</TableCell><TableCell>{stage.stage === 3 ? <span className="rounded-full bg-muted px-2.5 py-1 text-xs">无需业务确认</span> : file.confirmed ? <span className="rounded-full bg-success-soft px-2.5 py-1 text-xs text-success-strong">已确认</span> : canBusinessConfirm ? <Button variant="outline" size="xs" onClick={() => toast.info("材料确认接口尚未接入")}>确认</Button> : <span className="rounded-full bg-warning-soft px-2.5 py-1 text-xs text-warning">待确认</span>}</TableCell><TableCell className="text-right"><Button variant="link" size="sm" onClick={() => toast.info("原始文件内容将在文件服务接入后下载")}>下载</Button></TableCell></TableRow>)}</TableBody></Table> : <div className="rounded-xl border border-dashed border-border p-6 text-center text-xs text-muted-foreground">本环节暂无材料</div>}{current && <div className="flex flex-wrap gap-2">{canUpload && <Button variant="outline" size="sm" onClick={() => chooseWorkflowFile(workflow.workflowId, stage.stage)}><FileUp />上传材料</Button>}{canFeedback && stage.stage === 2 && <Button variant="outline" size="sm" onClick={() => toast.info("流程反馈接口尚未接入")}>填写反馈</Button>}{canBusinessConfirm && stage.stage === 5 && <Button variant="outline" size="sm" onClick={() => toast.info("流程确认接口尚未接入")}>确认评审材料</Button>}<Button variant="outline" size="sm" onClick={() => toast.info("催办通知接口尚未接入")}><Bell />发起催办</Button>{canAdvance && stage.stage < 7 && <Button size="sm" onClick={() => toast.info("流程推进接口尚未接入")}>推进下一环节 <ArrowRight /></Button>}{canOnline && stage.stage === 7 && <Button size="sm" onClick={() => toast.info("模型上线接口尚未接入")}>确认上线 <ArrowRight /></Button>}</div>}</CardContent>}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : <Card><CardContent className="py-16 text-center text-sm text-muted-foreground">当前筛选条件下没有流程</CardContent></Card>}
|
||||
</div>
|
||||
|
||||
<Dialog open={newRequestOpen} onOpenChange={setNewRequestOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>发起模型需求</DialogTitle><DialogDescription>支持单银行需求,也可输入多个银行并选择复用通用模型。</DialogDescription></DialogHeader>
|
||||
<div className="space-y-4"><label className="flex flex-col gap-1.5"><span className="text-xs font-medium text-ink-caption">银行</span><Input value={newBank} onChange={(event) => setNewBank(event.target.value)} placeholder="多个银行使用顿号分隔" /></label><FilterSelect label="模型大类" value={newCategory} allLabel="请选择" options={MODEL_CATEGORIES.map((item) => ({ label: item.name, value: item.id }))} onChange={setNewCategory} /><FilterSelect label="模型来源" value={newReuse} allLabel="请选择" options={["独立开发", "复用通用模型"]} onChange={setNewReuse} /></div>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setNewRequestOpen(false)}>取消</Button><Button onClick={() => { if (!newBank.trim()) { toast.error("请输入银行"); return; } setNewRequestOpen(false); toast.info("需求提交接口尚未接入"); }}>提交需求</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { AbnormalLevel, ModelCategoryId, ModelGrade } from "./modelData";
|
||||
|
||||
export type MonitoringRule = {
|
||||
id: number;
|
||||
ranking: "相符" | "不符";
|
||||
ksBand: string;
|
||||
psiBand: string;
|
||||
dropBand: string;
|
||||
abnormal: AbnormalLevel;
|
||||
grade: ModelGrade;
|
||||
reason: string;
|
||||
action: string;
|
||||
};
|
||||
|
||||
export type ThresholdConfig = {
|
||||
ks: number;
|
||||
psiLow: number;
|
||||
psiHigh: number;
|
||||
ksDrop: number;
|
||||
interventionKs: number;
|
||||
interventionPsi: number;
|
||||
};
|
||||
|
||||
export const BASE_THRESHOLDS: ThresholdConfig = {
|
||||
ks: 40,
|
||||
psiLow: 10,
|
||||
psiHigh: 25,
|
||||
ksDrop: 20,
|
||||
interventionKs: 30,
|
||||
interventionPsi: 50,
|
||||
};
|
||||
|
||||
export const CATEGORY_THRESHOLDS: Record<ModelCategoryId, ThresholdConfig | null> = {
|
||||
std: null,
|
||||
bai: null,
|
||||
big: null,
|
||||
afd: null,
|
||||
};
|
||||
|
||||
export const MONITORING_RULES: MonitoringRule[] = [];
|
||||
|
||||
export const RULE_VERSIONS: Array<{ version: string; category: string; author: string; createdAt: string; current: boolean; note: string }> = [];
|
||||
|
||||
export type PromptKey = "A" | "BC";
|
||||
export const PROMPTS: Partial<Record<PromptKey, { name: string; text: string }>> = {};
|
||||
|
||||
export const PROMPT_VERSIONS: Array<{ version: string; author: string; createdAt: string; current: boolean; note: string }> = [];
|
||||
|
||||
export const PROMPT_REGRESSION: Array<{ sample: string; result: string; detail: string }> = [];
|
||||
|
||||
export const TEMPLATE_VERSIONS: Array<{ version: string; author: string; createdAt: string; current: boolean; note: string }> = [];
|
||||
|
||||
export const BANK_REPORT_CONFIG: Record<string, { frequency: string; day: number }> = {};
|
||||
@@ -0,0 +1,213 @@
|
||||
export const MODEL_CATEGORIES = [
|
||||
{ id: "std", name: "标准A卡" },
|
||||
{ id: "bai", name: "白户A卡" },
|
||||
{ id: "big", name: "大额A卡" },
|
||||
{ id: "afd", name: "反欺诈评分" },
|
||||
] as const;
|
||||
|
||||
export type ModelCategoryId = (typeof MODEL_CATEGORIES)[number]["id"];
|
||||
export type ModelGrade = "A" | "B" | "C";
|
||||
export type AbnormalLevel = "三级" | "二级" | "一级" | "正常";
|
||||
export type ModelStatus = "正常" | "陪跑" | "陪跑结束" | "下线";
|
||||
export type RankingStatus = "相符" | "不符" | "—";
|
||||
|
||||
export type ModelRecord = {
|
||||
bank: string;
|
||||
category: ModelCategoryId;
|
||||
name: string;
|
||||
modelId: string;
|
||||
version: string;
|
||||
status: ModelStatus;
|
||||
iteratedAt: string;
|
||||
ranking: RankingStatus;
|
||||
ks: number;
|
||||
psi: number;
|
||||
ksDrop: number;
|
||||
secondaryHits: number;
|
||||
wuji: boolean;
|
||||
processedAt: string | null;
|
||||
previousAdvice: string;
|
||||
commonModel: string | null;
|
||||
};
|
||||
|
||||
export type MonitoringRow = ModelRecord & {
|
||||
monitorMonth: string;
|
||||
};
|
||||
|
||||
export const MONITOR_MONTHS = [
|
||||
"2026-02",
|
||||
"2026-03",
|
||||
"2026-04",
|
||||
"2026-05",
|
||||
"2026-06",
|
||||
"2026-07",
|
||||
] as const;
|
||||
|
||||
// 业务数据全部来自后端。以下空集合仅保留页面类型和计算函数的接口,
|
||||
// 在真实接口补齐对应字段前不再提供本地样例数据。
|
||||
export const MODELS: ModelRecord[] = [];
|
||||
export const FEATURE_METRICS: Array<{
|
||||
key: string;
|
||||
name: string;
|
||||
iv: number;
|
||||
ivDrop: number;
|
||||
csi: number;
|
||||
csiRise: number;
|
||||
ksContribution: number;
|
||||
psiContribution: number;
|
||||
}> = [];
|
||||
export const SORTING_DISTRIBUTION = {
|
||||
bins: [] as string[],
|
||||
counts: [] as number[],
|
||||
badRates: [] as number[],
|
||||
};
|
||||
export const BANK_AVERAGE_CYCLE: Record<string, number> = {};
|
||||
|
||||
export type ModelLifecycle = {
|
||||
onlineAt: string | null;
|
||||
escortStartAt: string | null;
|
||||
escortEndAt: string | null;
|
||||
offlineAt: string | null;
|
||||
developer: string;
|
||||
developmentKs: number;
|
||||
developmentPsi: number;
|
||||
maxLift: number;
|
||||
};
|
||||
|
||||
export const MODEL_LIFECYCLE: Record<string, ModelLifecycle> = {};
|
||||
|
||||
export function categoryName(category: ModelCategoryId): string {
|
||||
return MODEL_CATEGORIES.find((item) => item.id === category)?.name ?? category;
|
||||
}
|
||||
|
||||
export function abnormalLevelOf(model: Pick<ModelRecord, "ranking" | "ks" | "psi" | "ksDrop">): AbnormalLevel {
|
||||
const { ranking, ks, psi, ksDrop } = model;
|
||||
if (ranking === "—") return "正常";
|
||||
if (ranking === "不符" && ks < 40 && psi > 10) return "三级";
|
||||
if (ranking === "相符" && ks < 40 && psi > 25) return "三级";
|
||||
if (ranking === "不符" && ks >= 40 && psi > 25) return "二级";
|
||||
if (ranking === "不符" && ks >= 40 && psi > 10 && psi <= 25 && ksDrop > 20) return "二级";
|
||||
if (ranking === "不符" && ks < 40 && psi <= 10) return "二级";
|
||||
if (ranking === "相符" && ks < 40 && psi <= 25) return "二级";
|
||||
if (ranking === "相符" && ks >= 40 && psi > 25 && ksDrop > 20) return "二级";
|
||||
if (ranking === "不符" && ks >= 40 && psi > 10 && psi <= 25 && ksDrop <= 20) return "一级";
|
||||
if (ranking === "不符" && ks >= 40 && psi <= 10) return "一级";
|
||||
if (ranking === "相符" && ks >= 40 && psi > 25 && ksDrop <= 20) return "一级";
|
||||
if (ranking === "相符" && ks >= 40 && psi > 10 && psi <= 25) return "一级";
|
||||
if (ranking === "相符" && ks >= 40 && psi <= 10 && ksDrop > 20) return "一级";
|
||||
return "正常";
|
||||
}
|
||||
|
||||
export function gradeOf(model: Pick<ModelRecord, "ranking" | "ks" | "psi" | "ksDrop" | "secondaryHits" | "status">): ModelGrade {
|
||||
if (model.status === "下线") return "A";
|
||||
const abnormal = abnormalLevelOf(model);
|
||||
if (abnormal === "三级") return "C";
|
||||
if (abnormal === "二级" && model.secondaryHits >= 4) return "C";
|
||||
if (abnormal === "正常") return "A";
|
||||
return "B";
|
||||
}
|
||||
|
||||
export function abnormalReasonOf(model: ModelRecord | MonitoringRow): string {
|
||||
const abnormal = abnormalLevelOf(model);
|
||||
if (abnormal === "正常") return "各项指标均在阈值内";
|
||||
|
||||
const reasons: string[] = [];
|
||||
if (model.ranking === "不符") reasons.push("排序性不符");
|
||||
if (model.ks < 40) reasons.push("KS 低于 40%");
|
||||
if (model.psi > 25) reasons.push("PSI 高于 25%");
|
||||
else if (model.psi > 10) reasons.push("PSI 处于 10%–25%");
|
||||
if (model.ksDrop > 20) reasons.push("KS 环比降幅高于 20%");
|
||||
|
||||
if (abnormal === "二级") {
|
||||
const result = model.secondaryHits >= 4 ? ",已触发监控结果升级为 C" : ",未达到累计升级条件";
|
||||
return `${reasons.join("、")};近 6 个月累计 ${model.secondaryHits} 次二级异常${result}`;
|
||||
}
|
||||
return reasons.join("、");
|
||||
}
|
||||
|
||||
export function modelTrend(
|
||||
_model: ModelRecord,
|
||||
_metric: "ks" | "psi",
|
||||
_months: readonly string[] = MONITOR_MONTHS,
|
||||
): number[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
export function monitoringRows(_source: ModelRecord[] = MODELS): MonitoringRow[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
export function monthsBetween(from: string, to: string): string[] {
|
||||
const [fromYear, fromMonth] = from.split("-").map(Number);
|
||||
const [toYear, toMonth] = to.split("-").map(Number);
|
||||
if (!fromYear || !fromMonth || !toYear || !toMonth) return [...MONITOR_MONTHS];
|
||||
const start = fromYear * 12 + fromMonth - 1;
|
||||
const end = toYear * 12 + toMonth - 1;
|
||||
if (start > end || end - start > 23) return [...MONITOR_MONTHS];
|
||||
return Array.from({ length: end - start + 1 }, (_, index) => {
|
||||
const value = start + index;
|
||||
const year = Math.floor(value / 12);
|
||||
const month = value % 12 + 1;
|
||||
return `${year}-${String(month).padStart(2, "0")}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function average(values: number[]): number {
|
||||
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0;
|
||||
}
|
||||
|
||||
export function categoryTrend(
|
||||
_category: ModelCategoryId,
|
||||
_metric: "ks" | "psi",
|
||||
_months: string[],
|
||||
_source: ModelRecord[] = MODELS,
|
||||
): number[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
export function bankTrend(
|
||||
_bank: string,
|
||||
_metric: "ks" | "psi",
|
||||
_months: string[],
|
||||
_source: ModelRecord[] = MODELS,
|
||||
): number[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
export function modelsTrend(
|
||||
_models: ModelRecord[],
|
||||
_metric: "ks" | "psi",
|
||||
_months: string[],
|
||||
): number[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
export function latestIterationDate(models: ModelRecord[]): string {
|
||||
return models.map((model) => model.iteratedAt).filter(Boolean).sort().at(-1) ?? "—";
|
||||
}
|
||||
|
||||
export function modelIterationCycleMonths(model: ModelRecord): number | null {
|
||||
const lifecycle = MODEL_LIFECYCLE[model.modelId];
|
||||
const start = lifecycle?.onlineAt ?? lifecycle?.escortStartAt;
|
||||
if (!start || !model.iteratedAt) return null;
|
||||
const [startYear, startMonth] = start.slice(0, 7).split("-").map(Number);
|
||||
const [endYear, endMonth] = model.iteratedAt.slice(0, 7).split("-").map(Number);
|
||||
if (![startYear, startMonth, endYear, endMonth].every(Number.isFinite)) return null;
|
||||
return Math.max(0, (endYear * 12 + endMonth) - (startYear * 12 + startMonth));
|
||||
}
|
||||
|
||||
export function averageIterationCycle(models: ModelRecord[]): number | null {
|
||||
const values = models.map(modelIterationCycleMonths).filter((value): value is number => value !== null);
|
||||
return values.length ? Number(average(values).toFixed(1)) : null;
|
||||
}
|
||||
|
||||
export function averageIterationMonths(models: ModelRecord[]): number {
|
||||
const months = models
|
||||
.filter((model) => model.status !== "下线")
|
||||
.map((model) => {
|
||||
const [year, month] = model.iteratedAt.slice(0, 7).split("-").map(Number);
|
||||
return (2026 * 12 + 7) - (year * 12 + month);
|
||||
})
|
||||
.filter(Number.isFinite);
|
||||
return Number(average(months).toFixed(1));
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useAuth, type AuthUser } from "~/context/AuthContext";
|
||||
|
||||
export type OperationsRole = "business" | "model" | "admin";
|
||||
export type OperationsPageKey =
|
||||
| "workbench"
|
||||
| "usage"
|
||||
| "models"
|
||||
| "banks"
|
||||
| "deployed-models"
|
||||
| "monitoring"
|
||||
| "monitoring-detail"
|
||||
| "reports"
|
||||
| "report-summary"
|
||||
| "workflows"
|
||||
| "knowledge"
|
||||
| "rules"
|
||||
| "prompts"
|
||||
| "settings";
|
||||
|
||||
export type OperationsAction =
|
||||
| "workflow:create"
|
||||
| "workflow:feedback"
|
||||
| "workflow:submit-material"
|
||||
| "workflow:advance"
|
||||
| "workflow:online"
|
||||
| "workflow:business-confirm"
|
||||
| "monitor:initial-review"
|
||||
| "monitor:final-review"
|
||||
| "report:edit"
|
||||
| "report:export"
|
||||
| "report:send"
|
||||
| "rules:publish"
|
||||
| "rules:rollback"
|
||||
| "rules:simulate"
|
||||
| "rules:manage"
|
||||
| "prompts:manage"
|
||||
| "prompts:regression"
|
||||
| "settings:manage";
|
||||
|
||||
const PAGE_ROLE_VISIBILITY: Record<OperationsPageKey, OperationsRole[]> = {
|
||||
workbench: ["business", "model", "admin"],
|
||||
usage: ["admin"],
|
||||
models: ["business", "model", "admin"],
|
||||
banks: ["business", "model", "admin"],
|
||||
"deployed-models": ["model", "admin"],
|
||||
monitoring: ["business", "model", "admin"],
|
||||
"monitoring-detail": ["business", "model", "admin"],
|
||||
reports: ["business", "model", "admin"],
|
||||
"report-summary": ["business", "model", "admin"],
|
||||
workflows: ["business", "model", "admin"],
|
||||
knowledge: ["model", "admin"],
|
||||
rules: ["model", "admin"],
|
||||
prompts: ["model", "admin"],
|
||||
settings: ["admin"],
|
||||
};
|
||||
|
||||
const ACTION_ROLE_VISIBILITY: Record<OperationsAction, OperationsRole[]> = {
|
||||
"workflow:create": ["business", "admin"],
|
||||
"workflow:feedback": ["business", "admin"],
|
||||
"workflow:submit-material": ["model", "admin"],
|
||||
"workflow:advance": ["model", "admin"],
|
||||
"workflow:online": ["model", "admin"],
|
||||
"workflow:business-confirm": ["business", "admin"],
|
||||
"monitor:initial-review": ["model", "admin"],
|
||||
"monitor:final-review": ["business", "admin"],
|
||||
"report:edit": ["model", "admin"],
|
||||
"report:export": ["business", "model", "admin"],
|
||||
"report:send": ["model", "admin"],
|
||||
"rules:publish": ["admin"],
|
||||
"rules:rollback": ["admin"],
|
||||
"rules:simulate": ["admin"],
|
||||
"rules:manage": ["admin"],
|
||||
"prompts:manage": ["model", "admin"],
|
||||
"prompts:regression": ["model", "admin"],
|
||||
"settings:manage": ["admin"],
|
||||
};
|
||||
|
||||
export const OPERATIONS_PAGE_PERMISSIONS: Record<OperationsPageKey, string> = {
|
||||
workbench: "operations:workbench:view",
|
||||
usage: "operations:usage:view",
|
||||
models: "operations:model-overview:view",
|
||||
banks: "operations:bank-overview:view",
|
||||
"deployed-models": "operations:deployed-models:view",
|
||||
monitoring: "operations:monitoring-overview:view",
|
||||
"monitoring-detail": "operations:monitoring-detail:view",
|
||||
reports: "operations:report:view",
|
||||
"report-summary": "operations:report-summary:view",
|
||||
workflows: "operations:workflow:view",
|
||||
knowledge: "operations:knowledge:view",
|
||||
rules: "operations:rules:view",
|
||||
prompts: "operations:prompt:view",
|
||||
settings: "operations:settings:view",
|
||||
};
|
||||
|
||||
export const OPERATIONS_ACTION_PERMISSIONS: Record<OperationsAction, string> = {
|
||||
"workflow:create": "operations:workflow:create",
|
||||
"workflow:feedback": "operations:workflow:feedback",
|
||||
"workflow:submit-material": "operations:workflow:submit-material",
|
||||
"workflow:advance": "operations:workflow:advance",
|
||||
"workflow:online": "operations:workflow:online",
|
||||
"workflow:business-confirm": "operations:workflow:confirm",
|
||||
"monitor:initial-review": "operations:monitoring-detail:model-review",
|
||||
"monitor:final-review": "operations:monitoring-detail:business-review",
|
||||
"report:edit": "operations:report:edit",
|
||||
"report:export": "operations:report:export",
|
||||
"report:send": "operations:report:send",
|
||||
"rules:publish": "operations:rules:publish",
|
||||
"rules:rollback": "operations:rules:rollback",
|
||||
"rules:simulate": "operations:rules:simulate",
|
||||
"rules:manage": "operations:rules:publish",
|
||||
"prompts:manage": "operations:prompt:edit",
|
||||
"prompts:regression": "operations:prompt:regression",
|
||||
"settings:manage": "operations:settings:view",
|
||||
};
|
||||
|
||||
export function resolveOperationsRole(user: AuthUser | null): OperationsRole {
|
||||
if (user?.is_system_admin || user?.role_code === "admin") return "admin";
|
||||
const code = user?.role_code?.toLowerCase() ?? "";
|
||||
if (["business", "business_team", "biz"].includes(code)) return "business";
|
||||
return "model";
|
||||
}
|
||||
|
||||
export function useOperationsRole(): OperationsRole {
|
||||
return resolveOperationsRole(useAuth().user);
|
||||
}
|
||||
|
||||
export function isOperationsPageVisibleForRole(role: OperationsRole, page: OperationsPageKey): boolean {
|
||||
return PAGE_ROLE_VISIBILITY[page].includes(role);
|
||||
}
|
||||
|
||||
export function isOperationsActionVisibleForRole(role: OperationsRole, action: OperationsAction): boolean {
|
||||
return ACTION_ROLE_VISIBILITY[action].includes(role);
|
||||
}
|
||||
|
||||
export function useOperationsPermission(permissionCode: string): boolean {
|
||||
const { user } = useAuth();
|
||||
return Boolean(user?.is_system_admin || user?.permissions.includes(permissionCode));
|
||||
}
|
||||
|
||||
export function useCanOperationsPage(page: OperationsPageKey): boolean {
|
||||
return useOperationsPermission(OPERATIONS_PAGE_PERMISSIONS[page]);
|
||||
}
|
||||
|
||||
export function useCanOperationsAction(action: OperationsAction): boolean {
|
||||
return useOperationsPermission(OPERATIONS_ACTION_PERMISSIONS[action]);
|
||||
}
|
||||
|
||||
export function operationsPagePermission(page: OperationsPageKey): string {
|
||||
return OPERATIONS_PAGE_PERMISSIONS[page];
|
||||
}
|
||||
|
||||
export function operationsPageFromPath(pathname: string): OperationsPageKey {
|
||||
const normalized = pathname.replace(/^\/operations\/?/, "");
|
||||
if (normalized.startsWith("monitoring/")) return "monitoring-detail";
|
||||
const path = normalized.split("/")[0] ?? "";
|
||||
if (!path) return "workbench";
|
||||
if (path === "monitoring") return "monitoring";
|
||||
if (path in PAGE_ROLE_VISIBILITY) return path as OperationsPageKey;
|
||||
return "workbench";
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export type ReportStatus = "待模型团队阅读" | "编辑中" | "已发送业务团队";
|
||||
export type ReportType = "监控报告" | "诊断报告";
|
||||
|
||||
export type MonitoringReport = {
|
||||
reportId: string;
|
||||
bank: string;
|
||||
modelName: string;
|
||||
modelId: string;
|
||||
version: string;
|
||||
monitorMonth: string;
|
||||
type: ReportType;
|
||||
status: ReportStatus;
|
||||
generatedAt: string;
|
||||
outputDate: string;
|
||||
unreadDays: number;
|
||||
synced: boolean;
|
||||
};
|
||||
|
||||
// 报告数据由后端报告接口提供。接口接入前保持为空,不使用本地样例。
|
||||
export const REPORTS: MonitoringReport[] = [];
|
||||
|
||||
export function latestReports(source: MonitoringReport[] = REPORTS): MonitoringReport[] {
|
||||
const latestMonth = source.reduce((latest, report) => report.monitorMonth > latest ? report.monitorMonth : latest, "");
|
||||
return source.filter((report) => report.monitorMonth === latestMonth);
|
||||
}
|
||||
|
||||
export function createReportForModel(modelId: string): MonitoringReport | null {
|
||||
void modelId;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
import { REPORTS, type MonitoringReport } from "./reportData";
|
||||
|
||||
type ReportStore = {
|
||||
reports: MonitoringReport[];
|
||||
updateReport: (reportId: string, updates: Partial<MonitoringReport>) => void;
|
||||
};
|
||||
|
||||
export const useReportStore = create<ReportStore>()((set) => ({
|
||||
reports: REPORTS,
|
||||
updateReport: (reportId, updates) => set((state) => ({
|
||||
reports: state.reports.map((report) => report.reportId === reportId ? { ...report, ...updates } : report),
|
||||
})),
|
||||
}));
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ModelCategoryId } from "./modelData";
|
||||
|
||||
export type WorkflowStage = {
|
||||
stage: number;
|
||||
title: string;
|
||||
businessDuty: string;
|
||||
modelDuty: string;
|
||||
};
|
||||
|
||||
export type WorkflowFile = {
|
||||
name: string;
|
||||
uploadedBy: string;
|
||||
uploadedAt: string;
|
||||
confirmed: boolean;
|
||||
};
|
||||
|
||||
export type WorkflowInstance = {
|
||||
workflowId: string;
|
||||
title: string;
|
||||
bank: string;
|
||||
category: ModelCategoryId;
|
||||
modelId: string;
|
||||
initiatedBy: string;
|
||||
initiatedAt: string;
|
||||
currentStage: number;
|
||||
completedAt?: string;
|
||||
reusedModel?: string;
|
||||
deadline?: string;
|
||||
plannedTestAt?: string;
|
||||
plannedOnlineAt?: string;
|
||||
stalledDays?: number;
|
||||
files: Partial<Record<number, WorkflowFile[]>>;
|
||||
};
|
||||
|
||||
export type KnowledgeDocument = WorkflowFile & {
|
||||
workflowId: string;
|
||||
workflowTitle: string;
|
||||
bank: string;
|
||||
modelName: string;
|
||||
modelVersion: string;
|
||||
modelId: string;
|
||||
stage: string;
|
||||
commonModel: boolean;
|
||||
};
|
||||
|
||||
export type UsageRecord = {
|
||||
person: string;
|
||||
team: "业务团队" | "模型团队" | "管理员";
|
||||
logins: number;
|
||||
requests: number;
|
||||
reportsRead: number;
|
||||
reportsDownloaded: number;
|
||||
lastLoginAt: string;
|
||||
};
|
||||
|
||||
export const WORKFLOW_STAGES: WorkflowStage[] = [
|
||||
{ stage: 1, title: "需求提出", businessDuty: "通过平台提出需求,平台通知模型团队", modelDuty: "首次响应需求" },
|
||||
{ stage: 2, title: "方案设计", businessDuty: "在最晚反馈时间前反馈模型设计方案意见", modelDuty: "提交模型设计方案并设置最晚反馈时间" },
|
||||
{ stage: 3, title: "开发迭代", businessDuty: "无需确认开发材料", modelDuty: "通过模型平台开发或迭代,提交最终开发结果材料" },
|
||||
{ stage: 4, title: "模型验证", businessDuty: "支持上传业务验证材料", modelDuty: "提交新老模型对比数据及预计评审时间" },
|
||||
{ stage: 5, title: "评审决议", businessDuty: "确认最终评审材料并设置测试或陪跑预计日期", modelDuty: "提交会议结论、修改意见结果与最终材料" },
|
||||
{ stage: 6, title: "测试陪跑", businessDuty: "确认测试文件和一致性报告", modelDuty: "提交模型测试文件、一致性报告等材料" },
|
||||
{ stage: 7, title: "部署上线", businessDuty: "确认后设置预计上线时间", modelDuty: "确认模型正式上线并登记是否为通用模型" },
|
||||
];
|
||||
|
||||
export const WORKFLOWS: WorkflowInstance[] = [];
|
||||
|
||||
export const USAGE_RECORDS: UsageRecord[] = [];
|
||||
|
||||
export function workflowDocuments(): KnowledgeDocument[] {
|
||||
return WORKFLOWS.flatMap((workflow) => Object.entries(workflow.files).flatMap(([stage, files]) => {
|
||||
const stageName = WORKFLOW_STAGES.find((item) => item.stage === Number(stage))?.title ?? "未知环节";
|
||||
return (files ?? []).map((file) => ({
|
||||
...file,
|
||||
workflowId: workflow.workflowId,
|
||||
workflowTitle: workflow.title,
|
||||
bank: workflow.bank,
|
||||
modelName: "—",
|
||||
modelVersion: "—",
|
||||
modelId: workflow.modelId,
|
||||
stage: stageName,
|
||||
commonModel: false,
|
||||
}));
|
||||
}));
|
||||
}
|
||||
@@ -1,18 +1,24 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { Navigate, useNavigate } from "react-router";
|
||||
|
||||
import { useAuth } from "~/context/AuthContext";
|
||||
import { useAuth, usePermission } from "~/context/AuthContext";
|
||||
import { DashboardPage } from "../admin/DashboardPage";
|
||||
import { useScriptWorkspaceStore } from "./state/scriptWorkspaceStore";
|
||||
|
||||
export default function DashboardRoute() {
|
||||
const canViewDevelopmentWorkbench = usePermission("dashboard:view");
|
||||
const { currentWorkspace } = useAuth();
|
||||
const scripts = useScriptWorkspaceStore((s) => s.scripts);
|
||||
const scriptCount = useScriptWorkspaceStore((s) => s.scriptCount);
|
||||
const loadScriptCount = useScriptWorkspaceStore((s) => s.loadScriptCount);
|
||||
const apiOnline = useScriptWorkspaceStore((s) => s.apiOnline);
|
||||
const workspaceId = useAuth().currentWorkspace?.workspace_id;
|
||||
const workspaceId = currentWorkspace?.workspace_id;
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!canViewDevelopmentWorkbench) {
|
||||
return <Navigate to="/operations" replace />;
|
||||
}
|
||||
|
||||
// Reload on workspace switch — DashboardRoute is not keyed by
|
||||
// workspace/user (only ScriptsPage is), so without this dep the
|
||||
// previous workspace's count would persist.
|
||||
@@ -31,4 +37,4 @@ export default function DashboardRoute() {
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
useScriptWorkspaceStore,
|
||||
} from "../state/scriptWorkspaceStore";
|
||||
|
||||
type ActivePage = "home" | "scripts" | "schedules" | "system";
|
||||
type ActivePage = "home" | "scripts" | "schedules" | "operations" | "system";
|
||||
|
||||
/**
|
||||
* 必须在 layout 层挂载,不能放在 ScriptsPage。
|
||||
|
||||
Reference in New Issue
Block a user