feat: add A-card operations frontend and backend foundation

This commit is contained in:
郑龙捷
2026-09-02 09:54:03 +08:00
parent 9badd3597f
commit ad71259ba5
106 changed files with 12461 additions and 1796 deletions
+6
View File
@@ -0,0 +1,6 @@
# Model-platform gateway used by the Vite development proxy.
VITE_GATEWAY_URL=http://127.0.0.1:8890
# "mock" keeps the A-card operations pages on local frontend fixtures.
# Switch to "api" when /api/v1/operations endpoints are available.
VITE_OPERATIONS_API_MODE=mock
+23
View File
@@ -19,4 +19,27 @@ pnpm install
pnpm dev
```
运维模块默认支持前端 Mock;当前本地 `frontend/.env` 已切换为后端 API
```bash
VITE_OPERATIONS_API_MODE=api
```
当前运维界面已同步对外原型 V1.11:模型大类与细分银行纵向卡片、银行/同业
双线趋势、待办/关注拆分、一屏监控明细以及监控诊断报告/历史报告汇总等最新术语。
切换后核心纵向链路会请求:
- `GET /api/v1/operations/models`
- `GET /api/v1/operations/models/{model_id}`
- `GET /api/v1/operations/models/{model_id}/monitor-results?month=YYYY-MM`
运维模块只消费登录接口返回的角色,不维护角色或权限。当前界面映射为:
- `admin` / `is_system_admin=true` → 管理员
- `developer` / `model_team` → 模型团队
- `business` / `business_team` / `biz` → 业务团队
菜单和操作按钮按登录角色适配;实际授权由独立权限模块负责。业务团队没有模型开发 Workspace 时,运维域使用“运维全局视图”,不会卡在 Workspace 加载状态。
生产构建由根目录 `nginx/Dockerfile` 完成,构建结果复制到 Nginx 静态目录。
+50 -1
View File
@@ -48,6 +48,26 @@
--spacing-gap-md: 9px;
}
@theme inline {
/* shadcn 语义色映射:STYLE_GUIDE §2.6 */
--color-background: var(--color-bg);
--color-foreground: var(--color-ink);
--color-card: var(--color-bg-panel);
--color-card-foreground: var(--color-ink);
--color-popover: var(--color-bg-panel);
--color-popover-foreground: var(--color-ink);
--color-primary: var(--color-brand);
--color-primary-foreground: #ffffff;
--color-secondary: var(--color-line-soft);
--color-secondary-foreground: var(--color-ink);
--color-muted: var(--color-line-soft);
--color-muted-foreground: var(--color-ink-muted);
--color-accent: var(--color-brand-soft);
--color-accent-foreground: var(--color-brand-strong);
--color-destructive: var(--color-danger);
--color-border: var(--color-line);
--color-input: var(--color-line-soft);
--color-ring: var(--color-brand);
--color-sidebar: var(--sidebar-background);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
@@ -273,6 +293,35 @@
}
}
@media print {
body.printing-operations-report {
min-width: 0;
overflow: visible;
background: var(--color-bg-panel);
}
body.printing-operations-report * {
visibility: hidden !important;
}
body.printing-operations-report .operations-report-document,
body.printing-operations-report .operations-report-document * {
visibility: visible !important;
}
body.printing-operations-report .operations-report-document {
position: absolute;
inset: 0;
width: 100%;
overflow: visible;
box-shadow: none;
}
body.printing-operations-report [data-slot="button"] {
display: none !important;
}
}
/* Shared UI classes still used by admin / schedules pages */
.icon-button {
display: grid;
@@ -300,4 +349,4 @@
background: linear-gradient(145deg, #3b92ed, #1869c9);
font-size: 14px;
font-weight: 700;
}
}
+7 -1
View File
@@ -24,10 +24,16 @@ export function Topbar({
onSetCurrentWorkspace,
onLogout,
}: TopbarProps) {
const roleLabel = user?.is_system_admin || user?.role_code === "admin"
? "管理员"
: ["business", "business_team", "biz"].includes(user?.role_code ?? "")
? "业务团队"
: "模型团队";
const pageTitles: Record<string, string> = {
home: "工作台",
scripts: "构建脚本",
schedules: "调度配置",
operations: "A卡模型运维",
system: "系统管理",
};
@@ -124,7 +130,7 @@ export function Topbar({
{user?.display_name ?? "未知用户"}
</strong>
<small className="text-[10px] text-[#97a3b1]">
{user?.role_code === "admin" ? "管理员" : "开发人员"}
{roleLabel}
</small>
</span>
</button>
+6 -2
View File
@@ -2,11 +2,15 @@ import * as React from "react"
import { cn } from "~/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
function Table({
className,
containerClassName,
...props
}: React.ComponentProps<"table"> & { containerClassName?: string }) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
className={cn("relative w-full overflow-x-auto", containerClassName)}
>
<table
data-slot="table"
@@ -0,0 +1,162 @@
import { useMemo, useRef, useState } from "react";
import { ArrowRight, Download, TrendingDown, TrendingUp } 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 { 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;
ownKs: number;
ownPsi: number;
peerKs: number;
peerPsi: number;
grades: Record<ModelGrade, number>;
demo?: boolean;
};
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, { id: "consumer-demo", name: "消费贷评分(示意)", models: [], modelCount: 1, versions: 1, latestIteration: "2026-07-18", averageCycle: 11, ownKs: 37.9, ownPsi: 16.6, peerKs: 39.6, peerPsi: 14.2, grades: { A: 0, B: 1, C: 0 }, demo: true }];
}, [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) => {
if (item.demo) return toast.info("该卡片仅用于展示细分银行第 5 个大类的纵向滚动效果");
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="mx-auto flex max-w-screen-2xl flex-col gap-6 pb-8">
<OperationsPageHeader title="细分银行概览" description="按银行和模型大类查看本行表现,并与同业均值对照。" actions={<Button variant="outline" onClick={() => navigate("/operations/deployed-models")}>线 <ArrowRight /></Button>} />
<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.demo && item.id === category;
const hasModels = item.modelCount > 0;
const activate = () => item.demo ? toast.info("扩展示意卡:正式接入第 5 个大类后沿用相同结构") : 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">{item.demo ? "扩展示意" : `银行-${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.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,213 @@
import { useMemo, useRef, useState } from "react";
import { ArrowRight, Download, FileSpreadsheet, FolderOpen, 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 { MODEL_LIFECYCLE, 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 metricSeries(model: ModelRecord, base: readonly number[], variance: number): number[] {
const seed = [...model.modelId].reduce((sum, character) => sum + character.charCodeAt(0), 0);
return base.map((value, index) => Number((value * (1 + ((seed >> index) % 7 - 3) / variance)).toFixed(2)));
}
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.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>
);
}
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 lifecycle = model ? MODEL_LIFECYCLE[model.modelId] : null;
const scoreLabels = ["低分段", "中低分", "中分段", "中高分", "高分段", "最高分"];
const rankingRates = model ? metricSeries(model, [9.8, 7.2, 5.1, 3.4, 2.0, 1.1], 28) : [];
const liftValues = model ? metricSeries(model, [3.2, 2.4, 1.7, 1.1, 0.7, 0.4], 20) : [];
const psiValues = model ? metricSeries(model, [1.2, 0.9, 0.6, 0.5, 0.4, 0.3], 20) : [];
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) return;
void exportRowsToExcel({
fileName: scoreFile?.name.replace(/\.xlsx?$/i, "") ?? `${model.bank}_${model.modelId}_${model.version}_评分逻辑`,
sheetName: "评分逻辑",
headers: ["入模特征", "划分区间", "对应评分"],
rows: [["age", "[18,25)", 12], ["age", "[25,35)", 26], ["income", "[0,5000)", 8], ["income", "[5000,+)", 31], ["query_3m", "[0,2]", 22], ["query_3m", "[3,+)", 6]],
});
};
return (
<section className="h-full overflow-auto bg-bg p-6">
<div className="mx-auto flex max-w-screen-2xl 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 && lifecycle ? (
<>
<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],
["开发人员", lifecycle.developer],
["上线日期", lifecycle.onlineAt ?? "—"],
["最近迭代", model.iteratedAt],
["陪跑开始", lifecycle.escortStartAt ?? "—"],
["陪跑结束", lifecycle.escortEndAt ?? "—"],
["下线日期", lifecycle.offlineAt ?? "—"],
["状态", 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>KS {lifecycle.developmentKs.toFixed(1)}% · LIFT {lifecycle.maxLift.toFixed(2)}</CardDescription></CardHeader>
<CardContent><BarList values={liftValues} labels={scoreLabels} suffix="" tone="brand" /></CardContent>
</Card>
<Card size="sm">
<CardHeader><CardTitle> · PSI</CardTitle><CardDescription> OOT · PSI {lifecycle.developmentPsi.toFixed(2)}%</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 ?? `${model.bank}_${model.modelId}_${model.version}_评分逻辑.xlsx`}</b><small className="text-xs text-muted-foreground">{scoreFile ? `本地待上传 · ${(scoreFile.size / 1024).toFixed(1)} KB · ${scoreFile.updatedAt}` : `模型团队 ${lifecycle.developer} · ${lifecycle.onlineAt ?? model.iteratedAt}`}</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>{[
["模型设计方案", "方案设计"], ["开发结果材料", "开发迭代"], ["新老模型对比", "模型验证"], ["评审会议纪要", "评审决议"], ["一致性报告", "测试陪跑"],
].map(([name, stage]) => <TableRow key={name}><TableCell className="font-medium text-foreground"><span className="flex items-center gap-2"><FolderOpen className="size-4 text-primary" />{name}</span></TableCell><TableCell>{stage}</TableCell><TableCell className="text-right"><Button variant="link" size="sm" onClick={() => navigate(`/operations/knowledge?modelId=${encodeURIComponent(model.modelId)}&stage=${encodeURIComponent(stage)}`)}></Button></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="mx-auto flex max-w-screen-2xl 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,167 @@
import { useMemo, useRef, useState } from "react";
import { ArrowRight, Download, TrendingDown, TrendingUp } 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 { 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,
categoryName,
categoryTrend,
gradeOf,
latestIterationDate,
modelIterationCycleMonths,
monthsBetween,
type ModelCategoryId,
type ModelGrade,
type ModelRecord,
} from "./modelData";
const CATEGORY_AVERAGE_CYCLE: Record<ModelCategoryId, number> = { std: 9.3, bai: 12, big: 8, afd: 14 };
type CategoryCardItem = {
id: string;
name: string;
models: ModelRecord[];
banks: number;
versions: number;
latestIteration: string;
averageCycle: number;
averageKs: number;
averagePsi: number;
gradeCounts: Record<ModelGrade, number>;
demo?: boolean;
};
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: CATEGORY_AVERAGE_CYCLE[item.id], averageKs: average(categoryModels.map((model) => model.ks)), averagePsi: average(categoryModels.map((model) => model.psi)), gradeCounts };
});
return [...realCards, { id: "consumer-demo", name: "消费贷评分(示意)", models: [], banks: 5, versions: 4, latestIteration: "2026-07-18", averageCycle: 10.6, averageKs: 39.6, averagePsi: 14.2, gradeCounts: { A: 2, B: 2, C: 1 }, demo: true }];
}, [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) => {
if (item.demo) return toast.info("该卡片仅用于展示第 5 个及以上模型大类的纵向滚动效果");
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="mx-auto flex max-w-screen-2xl flex-col gap-6 pb-8">
<OperationsPageHeader title="模型大类概览" description="按模型大类汇总全部银行;点击大类卡片可定位到对应指标趋势。" actions={<Button variant="outline" onClick={() => navigate("/operations/monitoring")}> <ArrowRight /></Button>} />
<CategoryCardRail>
{categoryCards.map((item) => {
const total = item.gradeCounts.A + item.gradeCounts.B + item.gradeCounts.C || 1;
const selected = !item.demo && item.id === category;
const activate = () => item.demo ? toast.info("扩展示意卡:正式接入第 5 个大类后沿用相同卡片结构") : 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">{item.demo ? "扩展示意" : "全部银行"}</span></CardTitle></CardHeader>
<CardContent className="space-y-3">
<dl className="grid grid-cols-3 gap-3">{[["银行数", item.banks], ["模型数", item.demo ? 5 : 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.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,337 @@
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 { REPORTS } from "./reportData";
import { useOperationsData, useOperationsModelDetail } from "./OperationsDataContext";
import { isOperationsActionVisibleForRole, useOperationsRole } 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 operationsRole = useOperationsRole();
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 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 linkedReport = REPORTS.find((report) => report.modelId === model.modelId && report.monitorMonth === "2026-07") ?? null;
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 = isOperationsActionVisibleForRole(operationsRole, "monitor:initial-review");
const canFinalReview = isOperationsActionVisibleForRole(operationsRole, "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="mx-auto max-w-screen-2xl 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="mx-auto flex max-w-screen-2xl 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>2026-07 · </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">{grade === "A" ? "模型监控报告" : "模型诊断报告"} · 2026-07 · 2026-08-15</p>
<Button className="mt-2 px-0" variant="link" size="sm" onClick={() => navigate(linkedReport ? `/operations/reports?report=${linkedReport.reportId}` : "/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>2026-07 · 线</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"> 2026-07 </p></div>
</div>
<div className="grid grid-cols-5 gap-4">
{[38, 27, 18, 11, 6].map((reference, index) => {
const current = Math.max(2, reference + [4, -3, 2, -1, -2][index]);
return (
<div className="rounded-xl border border-border p-4" key={reference}>
<span className="text-xs text-muted-foreground"> {index + 1}</span>
<div className="mt-3 space-y-2">
<div><span className="flex justify-between text-xs"><i></i><b>{reference}%</b></span><span className="mt-1 block h-2 overflow-hidden rounded-full bg-muted"><i className="block h-full bg-ink-subtle" style={{ width: `${reference * 2}%` }} /></span></div>
<div><span className="flex justify-between text-xs"><i></i><b>{current}%</b></span><span className="mt-1 block h-2 overflow-hidden rounded-full bg-muted"><i className="block h-full bg-primary" style={{ width: `${current * 2}%` }} /></span></div>
</div>
</div>
);
})}
</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="mx-auto flex max-w-screen-2xl 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,148 @@
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Database, 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,
listOperationsModels,
operationsApiMode,
type OperationsApiMode,
} from "~/services/operationsApi";
import type { ModelRecord, MonitoringRow } from "./modelData";
type OperationsDataContextValue = {
models: ModelRecord[];
loading: boolean;
error: string | null;
source: OperationsApiMode;
reload: () => Promise<void>;
};
const OperationsDataContext = createContext<OperationsDataContextValue | null>(null);
export function OperationsDataProvider({ children }: { children: ReactNode }) {
const { currentWorkspace } = useAuth();
const workspaceId = currentWorkspace?.workspace_id;
const [models, setModels] = useState<ModelRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async (signal?: AbortSignal) => {
setLoading(true);
setError(null);
try {
setModels(await listOperationsModels({ workspaceId, signal }));
} 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,
loading,
error,
source: operationsApiMode,
reload: () => load(),
}), [error, load, loading, models]);
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),
]).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, models, reload, source } = useOperationsData();
if (loading) {
return (
<section className="h-full overflow-auto bg-bg p-6" aria-busy="true" aria-label="正在加载运维数据">
<div className="mx-auto max-w-screen-2xl 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">{source === "api" ? "真实接口" : "前端 Mock"}</p>
<Button className="mt-5" onClick={() => void reload()}><RefreshCw /></Button>
</CardContent>
</Card>
</section>
);
}
if (!models.length) {
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-brand-soft text-primary"><Database className="size-6" /></span><h2 className="mt-4 text-xl font-bold text-foreground"></h2><p className="mt-2 text-sm text-muted-foreground"></p><Button className="mt-5" variant="outline" onClick={() => void reload()}><RefreshCw /></Button></CardContent></Card>
</section>
);
}
return children;
}
@@ -0,0 +1,382 @@
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,
}: {
label: string;
value: string;
options: Array<string | { label: string; value: string }>;
onChange: (value: string) => void;
allLabel?: string;
className?: string;
}) {
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"
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" };
}) {
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[];
}) {
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,225 @@
import { Activity, ArrowRight, Building2, FileChartColumn, Layers3, Radar } from "lucide-react";
import { useNavigate } 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 { GradeBadge, OperationsPageHeader } from "./OperationsUi";
import { useOperationsData } from "./OperationsDataContext";
import { MODEL_CATEGORIES, categoryName, gradeOf, type ModelGrade } from "./modelData";
export default function OperationsWorkbenchPage() {
const navigate = useNavigate();
const { models, source } = useOperationsData();
const liveModels = models.filter((model) => model.status !== "下线");
const bankCount = new Set(liveModels.map((model) => model.bank)).size;
const gradeCounts = liveModels.reduce<Record<ModelGrade, number>>(
(counts, model) => ({ ...counts, [gradeOf(model)]: counts[gradeOf(model)] + 1 }),
{ A: 0, B: 0, C: 0 },
);
const pending = liveModels
.filter((model) => gradeOf(model) !== "A")
.sort((left, right) => gradeOf(right).localeCompare(gradeOf(left)) || left.ks - right.ks)
.slice(0, 5);
const watchItems = [
{ title: "华东银行 · 标准A卡重构", detail: "当前阶段:方案设计 · 预计 2026-09-18 完成", path: "/operations/workflows" },
{ title: "滨海银行 · 大额A卡陪跑上线", detail: "当前阶段:模型上线 · 预计 2026-09-25 完成", path: "/operations/workflows" },
{ title: "南岭银行 · 白户A卡模型微调", detail: "当前阶段:开发评审 · 预计 2026-10-08 完成", path: "/operations/workflows" },
];
const metricCards = [
{ label: "在管模型", value: liveModels.length, detail: "覆盖 4 个模型大类", Icon: Layers3 },
{ label: "接入银行", value: bankCount, detail: "本月均已完成监控", Icon: Building2 },
{ label: "B / C 等级", value: gradeCounts.B + gradeCounts.C, detail: `${gradeCounts.C} 个需重点处理`, Icon: Radar },
{ label: "最新报告", value: 6, detail: "2026-07 监控周期", Icon: FileChartColumn },
];
return (
<section className="h-full overflow-auto bg-bg p-6">
<div className="mx-auto flex max-w-screen-2xl flex-col gap-6 pb-8">
<OperationsPageHeader
title="我的工作台"
description="独立展示模型上线后的运行状态、监控结果和待处理事项;模型开发侧功能保持不变。"
actions={(
<Button onClick={() => navigate("/operations/monitoring")}>
<Activity />
</Button>
)}
/>
<Card className="bg-[linear-gradient(125deg,var(--sidebar-background),var(--color-brand))] text-white ring-0">
<CardHeader>
<CardTitle className="text-xl font-bold text-white">2026 7 </CardTitle>
<CardDescription className="max-w-3xl text-white/80">
{liveModels.length} {gradeCounts.C} C
</CardDescription>
<CardAction>
<span className="inline-flex rounded-full bg-white/15 px-3 py-1.5 text-xs font-medium text-white">
{source === "api" ? "真实接口" : "Mock 数据"} · 2026-08-15 06:12
</span>
</CardAction>
</CardHeader>
<CardContent className="flex gap-2">
<Button className="bg-white text-brand hover:bg-white/90" onClick={() => navigate("/operations/models")}>
<ArrowRight />
</Button>
<Button className="border-white/30 bg-transparent text-white hover:bg-white/10" variant="outline" onClick={() => navigate("/operations/monitoring")}>
</Button>
<Button className="border-white/30 bg-transparent text-white hover:bg-white/10" variant="outline" onClick={() => navigate("/operations/banks")}>
</Button>
<Button className="border-white/30 bg-transparent text-white hover:bg-white/10" variant="outline" onClick={() => navigate("/operations/report-summary")}>
</Button>
</CardContent>
</Card>
<div className="grid grid-cols-4 gap-4">
{metricCards.map(({ label, value, detail, Icon }) => (
<Card key={label} size="sm">
<CardContent className="flex items-center gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-brand-soft text-primary">
<Icon className="size-5" />
</span>
<span className="min-w-0">
<strong className="block text-xl font-bold tabular-nums text-foreground">{value}</strong>
<span className="block text-sm font-medium text-foreground">{label}</span>
<small className="block truncate text-xs text-muted-foreground">{detail}</small>
</span>
</CardContent>
</Card>
))}
</div>
<div className="grid grid-cols-[minmax(0,0.8fr)_minmax(0,1.4fr)] gap-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{(["A", "B", "C"] as ModelGrade[]).map((grade) => {
const total = liveModels.length || 1;
const count = gradeCounts[grade];
return (
<button
className="group flex w-full cursor-pointer items-center gap-3 rounded-xl border border-border bg-background p-3 text-left transition-colors hover:bg-muted/50"
key={grade}
type="button"
onClick={() => navigate(`/operations/monitoring?grade=${grade}`)}
>
<GradeBadge grade={grade} />
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between text-sm">
<b>{grade === "A" ? "运行正常" : grade === "B" ? "需要关注" : "需要处理"}</b>
<strong className="tabular-nums">{count} </strong>
</span>
<span className="mt-2 block h-2 overflow-hidden rounded-full bg-muted">
<i className="block h-full rounded-full bg-primary transition-all" style={{ width: `${count / total * 100}%` }} />
</span>
</span>
<ArrowRight className="size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</button>
);
})}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
<CardAction>
<Button variant="ghost" size="sm" onClick={() => navigate("/operations/monitoring")}></Button>
</CardAction>
</CardHeader>
<CardContent className="px-0">
<Table>
<TableHeader>
<TableRow>
<TableHead> / </TableHead>
<TableHead>ID</TableHead>
<TableHead>KS</TableHead>
<TableHead>PSI</TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pending.map((model) => (
<TableRow key={model.modelId}>
<TableCell>
<strong className="block text-foreground">{model.bank}</strong>
<small className="text-muted-foreground">{categoryName(model.category)} · {model.version}</small>
</TableCell>
<TableCell className="font-mono text-xs">{model.modelId}</TableCell>
<TableCell className="tabular-nums">{model.ks.toFixed(2)}%</TableCell>
<TableCell className="tabular-nums">{model.psi.toFixed(2)}%</TableCell>
<TableCell><GradeBadge grade={gradeOf(model)} /></TableCell>
<TableCell className="text-right">
<Button variant="link" size="sm" onClick={() => navigate(`/operations/monitoring/${model.modelId}`)}>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
<CardAction><Button variant="ghost" size="sm" onClick={() => navigate("/operations/workflows")}></Button></CardAction>
</CardHeader>
<CardContent className="grid grid-cols-3 gap-4">
{watchItems.map((item) => (
<div className="flex min-w-0 items-start gap-3 rounded-xl border border-border bg-background p-4" key={item.title}>
<span className="mt-1 size-2 shrink-0 rounded-full bg-primary" />
<span className="min-w-0 flex-1"><b className="block truncate text-sm text-foreground">{item.title}</b><small className="mt-1 block text-xs leading-5 text-muted-foreground">{item.detail}</small></span>
<Button className="shrink-0" variant="outline" size="xs" onClick={() => navigate(item.path)}></Button>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
<CardAction>
<Button variant="outline" size="sm" onClick={() => navigate("/operations/models")}></Button>
</CardAction>
</CardHeader>
<CardContent className="grid grid-cols-4 gap-4">
{MODEL_CATEGORIES.map((category) => {
const models = liveModels.filter((model) => model.category === category.id);
return (
<button
className="group rounded-xl border border-border bg-background p-4 text-left transition-colors hover:border-primary/40 hover:bg-brand-soft/50"
key={category.id}
type="button"
onClick={() => navigate(`/operations/models?category=${category.id}`)}
>
<span className="flex items-center justify-between">
<b className="text-sm text-foreground">{category.name}</b>
<ArrowRight className="size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</span>
<strong className="mt-3 block text-xl font-bold tabular-nums text-foreground">{models.length}</strong>
<small className="text-xs text-muted-foreground">
{new Set(models.map((model) => model.bank)).size} · {new Set(models.map((model) => model.version)).size}
</small>
</button>
);
})}
</CardContent>
</Card>
</div>
</section>
);
}
@@ -0,0 +1,65 @@
import { Fragment, useState } from "react";
import { CheckCircle2, Edit3, RotateCcw, 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 { usePersistentState } from "~/hooks/use-persistent-state";
import { FilterSelect, OperationsPageHeader } from "./OperationsUi";
import { PROMPTS, PROMPT_REGRESSION, PROMPT_VERSIONS } from "./governanceData";
type PromptKey = keyof typeof PROMPTS;
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 [promptKey, setPromptKey] = useState<PromptKey>("BC");
const [texts, setTexts] = usePersistentState<Record<PromptKey, string>>("a-card-prompt-texts", { A: PROMPTS.A.text, BC: PROMPTS.BC.text });
const [editing, setEditing] = useState(false);
const [versions, setVersions] = usePersistentState("a-card-prompt-versions", PROMPT_VERSIONS);
const passed = PROMPT_REGRESSION.filter((item) => item.result === "通过").length;
const savePrompt = () => {
const version = `P${Number(versions[0]?.version.slice(1) ?? 5) + 1}`;
setVersions((current) => [{ version, author: "模型团队 李伟", createdAt: "2026-08-31 11:05", current: true, note: "手动保存并提交回归(Mock" }, ...current.map((item) => ({ ...item, current: false }))]);
setEditing(false);
toast.success(`已保存为 ${version} 并提交回归评审`);
};
return (
<section className="h-full overflow-auto bg-bg p-6">
<div className="mx-auto flex max-w-screen-2xl flex-col gap-6 pb-8">
<OperationsPageHeader title="报告 Prompt 管理" description="查看和调整提示词全文,保留版本记录,并通过历史样本回归后生效。" actions={<Button onClick={savePrompt}><Save /></Button>} />
<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={[{ label: PROMPTS.A.name, value: "A" }, { label: PROMPTS.BC.name, value: "BC" }]} onChange={(value) => { setPromptKey(value as PromptKey); setEditing(false); }} /><Button variant="outline" size="sm" onClick={() => setEditing((value) => !value)}><Edit3 />{editing ? "退出编辑" : "编辑"}</Button></CardAction></CardHeader>
<CardContent className="space-y-4">
{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]} />}
<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.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> : <Button variant="outline" size="icon-xs" aria-label={`回滚至 ${version.version}`} onClick={() => { setVersions((current) => current.map((item) => ({ ...item, current: item.version === version.version }))); toast.success(`Prompt 已模拟回滚至 ${version.version}`); }}><RotateCcw /></Button>}</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,176 @@
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 { isOperationsActionVisibleForRole, useOperationsRole } 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({ modelId }: { modelId: string }) {
const offset = [...modelId].reduce((sum, character) => sum + character.charCodeAt(0), 0) % 5;
const rows = [
{ bin: "(0,580]", count: 310 + offset * 7 },
{ bin: "[580,620)", count: 742 + offset * 11 },
{ bin: "[620,660)", count: 2645 + offset * 19 },
{ bin: "[660,700)", count: 4812 + offset * 23 },
{ bin: "[700,+)", count: 3657 + offset * 17 },
];
const total = rows.reduce((sum, row) => sum + row.count, 0);
let cumulative = 0;
return (
<Table>
<TableHeader><TableRow><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead></TableRow></TableHeader>
<TableBody>
{rows.map((row) => {
const share = row.count / total;
cumulative += share;
return <TableRow key={row.bin}><TableCell className="font-mono text-xs">{row.bin}</TableCell><TableCell className="tabular-nums">{row.count.toLocaleString("zh-CN")}</TableCell><TableCell className="tabular-nums">{(share * 100).toFixed(2)}%</TableCell><TableCell className="tabular-nums">{(cumulative * 100).toFixed(2)}%</TableCell></TableRow>;
})}
<TableRow><TableCell className="font-semibold"></TableCell><TableCell className="font-semibold tabular-nums">{total.toLocaleString("zh-CN")}</TableCell><TableCell className="font-semibold">100.00%</TableCell><TableCell></TableCell></TableRow>
</TableBody>
</Table>
);
}
export default function ReportPage() {
const navigate = useNavigate();
const { models } = useOperationsData();
const operationsRole = useOperationsRole();
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 && isOperationsActionVisibleForRole(operationsRole, "report:edit");
const canSend = !isHistorical && isOperationsActionVisibleForRole(operationsRole, "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="mx-auto flex max-w-screen-2xl 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 && <><Button variant="outline" onClick={printReport}><Download /> PDF</Button>{canEdit && <Button variant="outline" onClick={() => { updateReport(report.reportId, { status: "编辑中", synced: true }); toast.success("草稿已保存,并同步至历史报告汇总(Mock)"); }}><Save />稿</Button>}{canSend && report.status !== "已发送业务团队" && <Button onClick={() => { updateReport(report.reportId, { status: "已发送业务团队", unreadDays: 0, synced: true }); toast.success("已模拟发送至业务团队"); }}><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">
{[
["验证样本", "12,186 户"], ["坏样本", "842 户"], ["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 modelId={model.modelId} />
</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"> Mock</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="mx-auto flex max-w-screen-2xl 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,134 @@
import { useMemo, useState } from "react";
import { Download, RotateCcw, Save, SlidersHorizontal } from "lucide-react";
import { toast } from "sonner";
import { Button } from "~/components/ui/button";
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
import { exportRowsToExcel } from "~/lib/exportExcel";
import { usePersistentState } from "~/hooks/use-persistent-state";
import { AbnormalBadge, FilterSelect, GradeBadge, OperationsPageHeader } from "./OperationsUi";
import { MODEL_CATEGORIES, type ModelCategoryId, type ModelGrade, type ModelRecord } from "./modelData";
import { BASE_THRESHOLDS, MONITORING_RULES, RULE_VERSIONS, type MonitoringRule, type ThresholdConfig } from "./governanceData";
import { useOperationsData } from "./OperationsDataContext";
import { isOperationsActionVisibleForRole, useOperationsRole } from "./operationsRole";
type CategoryKey = "all" | ModelCategoryId;
function ruleMatches(model: ModelRecord, rule: MonitoringRule, cut: ThresholdConfig): boolean {
if (model.ranking !== rule.ranking) return false;
const ks = rule.ksBand === "<40%" ? model.ks < cut.ks : model.ks >= cut.ks;
const psi = rule.psiBand === ">10%" ? model.psi > cut.psiLow
: rule.psiBand === ">25%" ? model.psi > cut.psiHigh
: rule.psiBand === "10%-25%" ? model.psi > cut.psiLow && model.psi <= cut.psiHigh
: rule.psiBand === "<=10%" ? model.psi <= cut.psiLow
: model.psi <= cut.psiHigh;
const drop = rule.dropBand === "无条件" || (rule.dropBand === ">20%" ? model.ksDrop > cut.ksDrop : model.ksDrop <= cut.ksDrop);
return ks && psi && drop;
}
function judge(model: ModelRecord, cut: ThresholdConfig): ModelGrade | "—" {
if (model.status === "下线" || model.ranking === "—") return "—";
const rule = MONITORING_RULES.find((item) => ruleMatches(model, item, cut));
if (!rule) return "—";
if (rule.abnormal === "二级" && model.secondaryHits >= 4) return "C";
return rule.grade;
}
function NumberField({ label, value, disabled, onChange }: { label: string; value: number; disabled?: boolean; onChange: (value: number) => void }) {
return <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 operationsRole = useOperationsRole();
const canManage = isOperationsActionVisibleForRole(operationsRole, "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] = usePersistentState("a-card-rule-configs", initialConfigs);
const [simulation, setSimulation] = useState<ThresholdConfig>({ ...BASE_THRESHOLDS });
const [versions, setVersions] = usePersistentState("a-card-rule-versions", RULE_VERSIONS);
const activeCut = configs[category] ?? configs.all ?? BASE_THRESHOLDS;
const editable = canManage && (category === "all" || configs[category] !== null);
const scope = models.filter((model) => model.status !== "下线" && (category === "all" || model.category === category));
const before = useMemo(() => scope.map((model) => ({ model, grade: judge(model, activeCut) })), [activeCut, category]);
const after = useMemo(() => scope.map((model) => ({ model, grade: judge(model, simulation) })), [category, simulation]);
const changed = before.map((item, index) => ({ model: item.model, from: item.grade, to: after[index]?.grade ?? "—" })).filter((item) => item.from !== item.to);
const alertsBefore = before.filter((item) => item.grade === "B" || item.grade === "C");
const alertsAfter = after.filter((item) => item.grade === "B" || item.grade === "C");
const bankBefore = new Set(alertsBefore.map((item) => item.model.bank)).size;
const bankAfter = new Set(alertsAfter.map((item) => item.model.bank)).size;
const selectCategory = (value: string) => {
const next = value as CategoryKey;
setCategory(next);
setSimulation({ ...(configs[next] ?? configs.all ?? BASE_THRESHOLDS) });
};
const updateCut = (key: keyof ThresholdConfig, value: number) => {
if (!editable) return;
setConfigs((current) => ({ ...current, [category]: { ...(current[category] ?? current.all ?? BASE_THRESHOLDS), [key]: value } }));
};
const forkCategory = () => {
if (category === "all") return;
setConfigs((current) => ({ ...current, [category]: { ...(current.all ?? BASE_THRESHOLDS) } }));
toast.success("已创建大类独立切点配置");
};
const resetCategory = () => {
if (category === "all") return;
setConfigs((current) => ({ ...current, [category]: null }));
setSimulation({ ...(configs.all ?? BASE_THRESHOLDS) });
toast.success("已恢复继承基线切点");
};
const publishVersion = () => {
const version = `V${versions.length + 1}`;
setVersions((current) => [{ version, category: category === "all" ? "全部大类" : MODEL_CATEGORIES.find((item) => item.id === category)?.name ?? category, author: "管理员 王芳", createdAt: "2026-08-31 10:30", current: true, note: "手动发布(Mock" }, ...current.map((item) => ({ ...item, current: false }))]);
toast.success(`已模拟发布规则 ${version}`);
};
return (
<section className="h-full overflow-auto bg-bg p-6">
<div className="mx-auto flex max-w-screen-2xl flex-col gap-6 pb-8">
<OperationsPageHeader title="监控结果等级规则" description={canManage ? "维护规则矩阵、模型大类切点、版本留痕与阈值影响测算。" : "模型团队只读查看当前规则矩阵与版本记录。"} actions={canManage ? <Button onClick={publishVersion}><Save /></Button> : undefined} />
<Card>
<CardHeader className="border-b border-border"><CardTitle></CardTitle><CardDescription> × KS × PSI × KS </CardDescription><CardAction className="flex items-end gap-2"><FilterSelect className="w-48" label="适用大类" value={category} allLabel="请选择" options={[{ label: "全部大类", value: "all" }, ...MODEL_CATEGORIES.map((item) => ({ label: item.name, value: item.id }))]} onChange={selectCategory} /><Button variant="outline" size="sm" onClick={() => void exportRowsToExcel({ fileName: "监控等级规则", sheetName: "规则矩阵", headers: ["序号", "排序性", "KS", "PSI", "KS环比降幅", "异常等级", "监控结果分类", "命中原因", "应对机制"], rows: MONITORING_RULES.map((rule) => [rule.id, rule.ranking, rule.ksBand, rule.psiBand, rule.dropBand, rule.abnormal, rule.grade, rule.reason, rule.action]) })}><Download /> Excel</Button></CardAction></CardHeader>
<CardContent className="px-0">
<Table>
<TableHeader><TableRow><TableHead>#</TableHead><TableHead></TableHead><TableHead>KS</TableHead><TableHead>PSI</TableHead><TableHead>KS环比降幅</TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead></TableRow></TableHeader>
<TableBody>{MONITORING_RULES.map((rule) => <TableRow key={rule.id}><TableCell className="font-mono text-xs text-muted-foreground">{rule.id}</TableCell><TableCell className={rule.ranking === "相符" ? "text-success-strong" : "text-danger"}>{rule.ranking}</TableCell><TableCell>{rule.ksBand}</TableCell><TableCell>{rule.psiBand}</TableCell><TableCell>{rule.dropBand}</TableCell><TableCell><AbnormalBadge level={rule.abnormal} /></TableCell><TableCell><GradeBadge grade={rule.grade} /></TableCell><TableCell className="min-w-56 whitespace-normal text-muted-foreground">{rule.reason}{rule.abnormal === "二级" && <span className="mt-1 block text-xs"> 6 4 C</span>}</TableCell><TableCell className="min-w-52 whitespace-normal">{rule.action}</TableCell></TableRow>)}</TableBody>
</Table>
</CardContent>
<CardContent className="space-y-4 border-t border-border">
<div className="flex items-center gap-2"><span className={`rounded-full px-2.5 py-1 text-xs font-medium ${category === "all" || configs[category] ? "bg-brand-soft text-primary" : "bg-muted text-muted-foreground"}`}>{category === "all" ? "基线配置" : configs[category] ? "已定制" : "继承基线"}</span>{canManage && category !== "all" && (configs[category] ? <Button variant="outline" size="xs" onClick={resetCategory}>线</Button> : <Button variant="outline" size="xs" onClick={forkCategory}></Button>)}</div>
<div className="grid grid-cols-6 gap-3"><NumberField label="KS 分档线 (%)" value={activeCut.ks} disabled={!editable} onChange={(value) => updateCut("ks", value)} /><NumberField label="PSI 低档线 (%)" value={activeCut.psiLow} disabled={!editable} onChange={(value) => updateCut("psiLow", value)} /><NumberField label="PSI 高档线 (%)" value={activeCut.psiHigh} disabled={!editable} onChange={(value) => updateCut("psiHigh", value)} /><NumberField label="KS 环比降幅线 (%)" value={activeCut.ksDrop} disabled={!editable} onChange={(value) => updateCut("ksDrop", value)} /><NumberField label="干预线 KS < (%)" value={activeCut.interventionKs} disabled={!editable} onChange={(value) => updateCut("interventionKs", value)} /><NumberField label="干预线 PSI > (%)" value={activeCut.interventionPsi} disabled={!editable} onChange={(value) => updateCut("interventionPsi", value)} /></div>
<p className="rounded-xl bg-warning-soft p-3 text-xs leading-5 text-foreground">KS &lt; {activeCut.interventionKs}% PSI &gt; {activeCut.interventionPsi}% </p>
</CardContent>
</Card>
<div className="grid grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] gap-6">
<Card>
<CardHeader><CardTitle></CardTitle><CardDescription></CardDescription></CardHeader>
<CardContent className="space-y-3">{versions.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={() => { setVersions((current) => current.map((item) => ({ ...item, current: item.version === version.version }))); toast.success(`已模拟回滚至 ${version.version}`); }}></Button> : <span className="rounded-full bg-muted px-2.5 py-1 text-xs text-muted-foreground"></span>}</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,92 @@
import { useState } from "react";
import { Database, Download, History, RefreshCw, Save, Settings2, Users } from "lucide-react";
import { toast } from "sonner";
import { Button } from "~/components/ui/button";
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
import { exportRowsToExcel } from "~/lib/exportExcel";
import { usePersistentState } from "~/hooks/use-persistent-state";
import { 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] = usePersistentState("a-card-config-read-day", "15 日");
const [readPeriod, setReadPeriod] = usePersistentState("a-card-config-read-period", "上一周期(月)");
const [templates, setTemplates] = usePersistentState("a-card-template-versions", TEMPLATE_VERSIONS);
const [selectedBank, setSelectedBank] = useState("江城银行");
const [bankConfigs, setBankConfigs] = usePersistentState("a-card-bank-report-config", BANK_REPORT_CONFIG);
const currentBankConfig = bankConfigs[selectedBank] ?? { frequency: "月度", day: 15 };
const notificationRows = [
["通知通道", "短信 + 平台通知"],
["报告未阅读催办", "模型团队 5 个工作日 · 业务团队 5 个工作日"],
["监控结果未处理催办", "1 个月未选择处理建议"],
["开发评审环节停滞", "1 个月未更新记录"],
["B/C 等级预警", "每月汇总,短信 + 平台通知"],
];
const publishTemplate = () => {
const version = `T${Number(templates[0]?.version.slice(1) ?? 4) + 1}`;
setTemplates((current) => [{ version, author: "管理员 王芳", createdAt: "2026-08-31 11:30", current: true, note: "手动发布(Mock" }, ...current.map((item) => ({ ...item, current: false }))]);
toast.success(`报告模板 ${version} 已模拟发布`);
};
const updateBankConfig = (updates: Partial<{ frequency: string; day: number }>) => {
setBankConfigs((current) => ({ ...current, [selectedBank]: { ...currentBankConfig, ...updates } }));
};
return (
<section className="h-full overflow-auto bg-bg p-6">
<div className="mx-auto flex max-w-screen-2xl flex-col gap-6 pb-8">
<OperationsPageHeader title="运维系统配置" description="维护指标读取、报告模板、通知催办和银行报告周期。" actions={<Button onClick={() => toast.success("配置已保存至前端 Mock 状态")}><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.success("已模拟触发共享 DB 指标同步")}><RefreshCw /></Button>
<p className="rounded-xl bg-muted/50 p-4 text-xs leading-5 text-muted-foreground">KSPSIIVCSI</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.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={() => { setTemplates((current) => current.map((item) => ({ ...item, current: item.version === template.version }))); toast.success(`报告模板已模拟回滚至 ${template.version}`); }}></Button>}</div>)}</CardContent>
</Card>
</div>
<div className="grid grid-cols-2 gap-6">
<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><CardTitle className="flex items-center gap-2"><Users className="size-5 text-primary" /></CardTitle><CardDescription></CardDescription></CardHeader>
<CardContent className="space-y-4"><div className="rounded-xl bg-brand-soft p-4 text-sm leading-6 text-primary"> <code className="rounded bg-white/70 px-1.5 py-0.5 font-mono text-xs">role_code</code></div><div className="grid grid-cols-3 gap-3">{[["业务团队", "business / business_team"], ["模型团队", "developer / model_team"], ["管理员", "admin"]].map(([label, code]) => <div className="rounded-xl border border-border p-4" key={label}><b className="block text-sm text-foreground">{label}</b><small className="mt-1 block font-mono text-xs text-muted-foreground">{code}</small></div>)}</div><p className="text-xs leading-5 text-muted-foreground"></p></CardContent>
</Card>
</div>
<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={["月度", "季度", "半年度", "年度"]} onChange={(frequency) => updateBankConfig({ frequency })} /><label className="flex flex-col gap-1.5"><span className="text-xs font-medium text-ink-caption"></span><Input 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={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).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>)}</TableBody></Table></CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="flex items-center gap-2"><Settings2 className="size-5 text-primary" /></CardTitle><CardDescription></CardDescription><CardAction><span className="rounded-full bg-muted px-2.5 py-1 text-xs text-muted-foreground"></span></CardAction></CardHeader>
<CardContent className="space-y-4"><div className="rounded-xl bg-warning-soft p-4 text-sm leading-6 text-foreground">线</div><div className="rounded-xl bg-bg-log p-5 font-mono text-xs leading-6 text-white/80"> × ID<br /> / / / / 线 / / / / / / 线</div></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} 人 · 人均 ${(sum(business, "logins") / business.length).toFixed(1)}`, Icon: LogIn },
{ label: "模型团队登录", value: sum(model, "logins"), detail: `${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="mx-auto flex max-w-screen-2xl 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.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>;
})}</TableBody>
</Table>
</CardContent>
</Card>
</div>
</section>
);
}
@@ -0,0 +1,176 @@
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 { usePersistentState } from "~/hooks/use-persistent-state";
import { FilterSelect, OperationsPageHeader } from "./OperationsUi";
import { MODEL_CATEGORIES, categoryName, type ModelCategoryId } from "./modelData";
import { WORKFLOWS, WORKFLOW_STAGES, type WorkflowFile, type WorkflowInstance } from "./workflowData";
import { isOperationsActionVisibleForRole, 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 = isOperationsActionVisibleForRole(operationsRole, "workflow:create");
const canAdvance = isOperationsActionVisibleForRole(operationsRole, "workflow:advance");
const canBusinessConfirm = isOperationsActionVisibleForRole(operationsRole, "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] = usePersistentState<Record<string, WorkflowFile[]>>("a-card-workflow-local-files", {});
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.success("材料已加入本地上传队列");
};
return (
<section className="h-full overflow-auto bg-bg p-6">
<div className="mx-auto flex max-w-screen-2xl 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 = canAdvance || (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.success("已模拟确认材料")}></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>}<Button variant="outline" size="sm" onClick={() => toast.success("已模拟发送催办通知")}><Bell /></Button>{canAdvance && stage.stage < 7 && <Button size="sm" onClick={() => toast.success("已模拟推进到下一环节")}> <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.success(`已模拟发起需求:${newBank} · ${categoryName(newCategory as ModelCategoryId)} · ${newReuse}`); }}></Button></DialogFooter>
</DialogContent>
</Dialog>
</section>
);
}
@@ -0,0 +1,101 @@
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[] = [
{ id: 1, ranking: "不符", ksBand: "<40%", psiBand: ">10%", dropBand: "无条件", abnormal: "三级", grade: "C", reason: "排序性不符 + KS 偏低 + PSI 偏移", action: "诊断报告,评估模型微调或重构必要性" },
{ id: 2, ranking: "相符", ksBand: "<40%", psiBand: ">25%", dropBand: "无条件", abnormal: "三级", grade: "C", reason: "KS 偏低 + PSI 明显偏移", action: "诊断报告,评估模型微调或重构必要性" },
{ id: 3, ranking: "不符", ksBand: ">=40%", psiBand: ">25%", dropBand: "无条件", abnormal: "二级", grade: "B", reason: "排序性不符、PSI 高", action: "诊断报告;累计升级后评估微调或重构" },
{ id: 4, ranking: "不符", ksBand: ">=40%", psiBand: "10%-25%", dropBand: ">20%", abnormal: "二级", grade: "B", reason: "排序性不符、PSI 中、KS 环比降幅高", action: "诊断报告;累计升级后评估微调或重构" },
{ id: 5, ranking: "不符", ksBand: "<40%", psiBand: "<=10%", dropBand: "无条件", abnormal: "二级", grade: "B", reason: "排序性不符、KS 低", action: "诊断报告;累计升级后评估微调或重构" },
{ id: 6, ranking: "相符", ksBand: "<40%", psiBand: "<=25%", dropBand: "无条件", abnormal: "二级", grade: "B", reason: "KS 低", action: "诊断报告;累计升级后评估微调或重构" },
{ id: 7, ranking: "相符", ksBand: ">=40%", psiBand: ">25%", dropBand: ">20%", abnormal: "二级", grade: "B", reason: "PSI 高、KS 环比降幅高", action: "诊断报告;累计升级后评估微调或重构" },
{ id: 8, ranking: "不符", ksBand: ">=40%", psiBand: "10%-25%", dropBand: "<=20%", abnormal: "一级", grade: "B", reason: "排序性不符 + PSI 轻度偏移", action: "诊断报告" },
{ id: 9, ranking: "不符", ksBand: ">=40%", psiBand: "<=10%", dropBand: "无条件", abnormal: "一级", grade: "B", reason: "排序性不符", action: "诊断报告" },
{ id: 10, ranking: "相符", ksBand: ">=40%", psiBand: ">25%", dropBand: "<=20%", abnormal: "一级", grade: "B", reason: "PSI 明显偏移", action: "诊断报告" },
{ id: 11, ranking: "相符", ksBand: ">=40%", psiBand: "10%-25%", dropBand: "无条件", abnormal: "一级", grade: "B", reason: "PSI 轻度偏移", action: "诊断报告" },
{ id: 12, ranking: "相符", ksBand: ">=40%", psiBand: "<=10%", dropBand: ">20%", abnormal: "一级", grade: "B", reason: "KS 环比下滑明显", action: "诊断报告" },
{ id: 13, ranking: "相符", ksBand: ">=40%", psiBand: "<=10%", dropBand: "<=20%", abnormal: "正常", grade: "A", reason: "各项指标均在阈值内", action: "监控报告" },
];
export const RULE_VERSIONS = [
{ version: "V3", category: "全部大类", author: "管理员 王芳", createdAt: "2026-07-02 10:24", current: true, note: "新增 KS 环比降幅维度" },
{ version: "V2", category: "全部大类", author: "管理员 王芳", createdAt: "2026-03-15 14:08", current: false, note: "PSI 分档由两档改三档" },
{ version: "V1", category: "全部大类", author: "管理员 王芳", createdAt: "2025-11-20 09:41", current: false, note: "初版" },
];
export const PROMPTS = {
A: {
name: "监控报告模板(A 等级)",
text: `你是一名信贷风险模型分析师。请依据平台程序取数的指标,撰写月度模型监控报告。\n\n【硬性要求】\n1. 所有数值只使用给定值,禁止自行计算。\n2. 正文不得出现 A/B/C 等级、判级或评级字样。\n3. 仅撰写监控结论、排序性、KS、PSI 四节。\n4. 语气客观,不做超出数据的推断。\n\n【平台注入变量】\n银行:{{bank}}\n模型名称:{{model_name}}\n模型版本:{{model_ver}}\n模型ID{{model_id}}\n报告周期:{{period}}\n排序性:{{rank_result}}\nKS{{ks}}%\nKS 环比降幅:{{ks_drop}}%\nPSI(滚动基准期):{{psi_roll}}%\n近 6 期趋势:{{trend_6m}}`,
},
BC: {
name: "诊断报告模板(B / C 等级)",
text: `你是一名信贷风险模型分析师。请依据平台程序取数的指标,撰写月度模型诊断报告。\n\n【硬性要求】\n1. 所有数值只使用给定值,禁止自行计算。\n2. 正文不得出现 A/B/C 等级、判级或评级字样。\n3. 结构包含监控结论、排序性、KS、PSI、IV、CSI、归因与建议。\n4. 归因只能基于命中规则与特征级指标,不得臆测业务原因。\n\n【平台注入变量】\n银行:{{bank}}\n模型名称:{{model_name}}\n模型版本:{{model_ver}}\n模型ID{{model_id}}\n报告周期:{{period}}\n排序性:{{rank_result}}\nKS{{ks}}%\nKS 环比降幅:{{ks_drop}}%\nPSI(滚动):{{psi_roll}}%\n命中规则:{{hit_rule}}\n异常等级:{{ab_level}}\n近 6 个月二级次数:{{lv2_6m}}\n特征级 IV{{iv_by_feature}}\n特征级 CSI{{csi_by_feature}}\n各特征分布变化:{{dist_shift}}`,
},
} as const;
export const PROMPT_VERSIONS = [
{ version: "P5", author: "模型团队 李伟", createdAt: "2026-08-02 15:20", current: true, note: "加入禁止自行计算硬约束" },
{ version: "P4", author: "模型团队 李伟", createdAt: "2026-06-11 10:05", current: false, note: "正文禁止出现等级字样" },
{ version: "P3", author: "模型团队 王芳", createdAt: "2026-04-08 16:48", current: false, note: "归因段落收敛,禁止臆测业务原因" },
];
export const PROMPT_REGRESSION = [
{ sample: "2026-06 江城银行 标准A卡", result: "通过", detail: "数值一致,无等级字样" },
{ sample: "2026-06 通汇银行 标准A卡", result: "通过", detail: "数值一致,归因引用命中规则" },
{ sample: "2026-05 华东银行 反欺诈评分", result: "通过", detail: "—" },
{ sample: "2026-05 南岭银行 白户A卡", result: "待复核", detail: "CSI 表述偏笼统" },
{ sample: "2026-04 云岭银行 标准A卡", result: "通过", detail: "—" },
];
export const TEMPLATE_VERSIONS = [
{ version: "T4", author: "管理员 王芳", createdAt: "2026-08-01 16:30", current: true, note: "诊断报告增加 IV / CSI 段落" },
{ version: "T3", author: "管理员 王芳", createdAt: "2026-05-18 11:12", current: false, note: "隐藏监控结果等级字样" },
{ version: "T2", author: "管理员 王芳", createdAt: "2026-02-09 09:55", current: false, note: "调整排序性图表位置" },
];
export const BANK_REPORT_CONFIG: Record<string, { frequency: string; day: number }> = {
: { frequency: "月度", day: 15 },
: { frequency: "月度", day: 15 },
: { frequency: "月度", day: 18 },
: { frequency: "季度", day: 15 },
: { frequency: "月度", day: 20 },
: { frequency: "半年度", day: 15 },
: { frequency: "年度", day: 15 },
};
@@ -0,0 +1,385 @@
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[] = [
{
bank: "江城银行", category: "std", name: "标准A卡", modelId: "JC-STD-001", version: "v2.3",
status: "正常", iteratedAt: "2026-06-18", ranking: "不符", ks: 36.84, psi: 27.4,
ksDrop: 24.1, secondaryHits: 3, wuji: true, processedAt: "2026-06-18",
previousAdvice: "建议启动模型微调或重构评估", commonModel: "标准A卡通用版 v2.3",
},
{
bank: "滨海银行", category: "std", name: "标准A卡", modelId: "BH-STD-001", version: "v2.3",
status: "陪跑", iteratedAt: "2026-07-28", ranking: "相符", ks: 44.1, psi: 6.2,
ksDrop: 3.4, secondaryHits: 0, wuji: false, processedAt: "2026-06-03",
previousAdvice: "维持现状,继续监控", commonModel: "标准A卡通用版 v2.3",
},
{
bank: "华东银行", category: "std", name: "标准A卡", modelId: "HD-STD-001", version: "v2.2",
status: "正常", iteratedAt: "2026-03-20", ranking: "相符", ks: 43.5, psi: 8.8,
ksDrop: 6.1, secondaryHits: 0, wuji: false, processedAt: "2026-05-20",
previousAdvice: "维持现状,继续监控", commonModel: "标准A卡通用版 v2.3",
},
{
bank: "南岭银行", category: "std", name: "标准A卡", modelId: "NL-STD-001", version: "v1.6",
status: "正常", iteratedAt: "2025-08-22", ranking: "相符", ks: 41.12, psi: 13.6,
ksDrop: 22.5, secondaryHits: 1, wuji: false, processedAt: "2026-06-12",
previousAdvice: "建议重点关注 PSI 与 KS 环比变化", commonModel: "标准A卡通用版 v2.3",
},
{
bank: "云岭银行", category: "std", name: "标准A卡", modelId: "YL-STD-001", version: "v2.1",
status: "正常", iteratedAt: "2026-01-09", ranking: "相符", ks: 45.8, psi: 5.1,
ksDrop: 2.2, secondaryHits: 0, wuji: false, processedAt: "2026-04-28",
previousAdvice: "维持现状,继续监控", commonModel: "标准A卡通用版 v2.3",
},
{
bank: "通汇银行", category: "std", name: "标准A卡", modelId: "TH-STD-001", version: "v2.0",
status: "正常", iteratedAt: "2025-11-30", ranking: "不符", ks: 38.73, psi: 9.4,
ksDrop: 11.8, secondaryHits: 4, wuji: false, processedAt: "2026-05-16",
previousAdvice: "建议开展模型微调可行性评估", commonModel: "标准A卡通用版 v2.3",
},
{
bank: "北岸银行", category: "std", name: "标准A卡", modelId: "BA-STD-001", version: "v1.9",
status: "下线", iteratedAt: "2024-12-11", ranking: "—", ks: 0, psi: 0,
ksDrop: 0, secondaryHits: 0, wuji: false, processedAt: "2026-03-02",
previousAdvice: "模型已下线,不再参与月度监控", commonModel: null,
},
{
bank: "江城银行", category: "big", name: "大额A卡", modelId: "JC-BIG-001", version: "v1.4",
status: "正常", iteratedAt: "2026-07-03", ranking: "相符", ks: 46.3, psi: 4.8,
ksDrop: 1.5, secondaryHits: 0, wuji: false, processedAt: "2026-06-24",
previousAdvice: "维持现状,继续监控", commonModel: "大额A卡通用版 v1.4",
},
{
bank: "华东银行", category: "big", name: "大额A卡", modelId: "HD-BIG-001", version: "v1.3",
status: "正常", iteratedAt: "2026-04-16", ranking: "相符", ks: 44.9, psi: 7.3,
ksDrop: 4.9, secondaryHits: 0, wuji: false, processedAt: "2026-05-30",
previousAdvice: "维持现状,继续监控", commonModel: "大额A卡通用版 v1.4",
},
{
bank: "云岭银行", category: "big", name: "大额A卡", modelId: "YL-BIG-001", version: "v1.2",
status: "陪跑结束", iteratedAt: "2026-08-05", ranking: "相符", ks: 42.7, psi: 9.6,
ksDrop: 8.2, secondaryHits: 0, wuji: false, processedAt: null,
previousAdvice: "暂无历史处理建议", commonModel: "大额A卡通用版 v1.4",
},
{
bank: "滨海银行", category: "big", name: "大额A卡", modelId: "BH-BIG-001", version: "v1.1",
status: "正常", iteratedAt: "2025-09-25", ranking: "相符", ks: 39.4, psi: 11.2,
ksDrop: 9.7, secondaryHits: 2, wuji: false, processedAt: "2026-04-19",
previousAdvice: "建议持续跟踪,下期复核", commonModel: "大额A卡通用版 v1.4",
},
{
bank: "南岭银行", category: "bai", name: "白户A卡", modelId: "NL-BAI-001", version: "v1.5",
status: "正常", iteratedAt: "2026-05-22", ranking: "不符", ks: 34.2, psi: 18.7,
ksDrop: 15.3, secondaryHits: 2, wuji: true, processedAt: "2026-06-09",
previousAdvice: "建议启动模型微调或重构评估", commonModel: null,
},
{
bank: "通汇银行", category: "bai", name: "白户A卡", modelId: "TH-BAI-001", version: "v1.4",
status: "正常", iteratedAt: "2026-02-11", ranking: "相符", ks: 37.6, psi: 12.1,
ksDrop: 7.4, secondaryHits: 1, wuji: false, processedAt: "2026-05-11",
previousAdvice: "建议重点关注并持续跟踪", commonModel: "白户A卡通用版 v1.4",
},
{
bank: "江城银行", category: "bai", name: "白户A卡", modelId: "JC-BAI-001", version: "v1.3",
status: "正常", iteratedAt: "2025-12-05", ranking: "相符", ks: 40.8, psi: 9.9,
ksDrop: 5.6, secondaryHits: 0, wuji: false, processedAt: "2026-03-26",
previousAdvice: "维持现状,继续监控", commonModel: "白户A卡通用版 v1.4",
},
{
bank: "华东银行", category: "afd", name: "反欺诈评分", modelId: "HD-AFD-001", version: "v2.0",
status: "正常", iteratedAt: "2026-04-10", ranking: "不符", ks: 28.6, psi: 31.5,
ksDrop: 18.9, secondaryHits: 3, wuji: false, processedAt: "2026-06-21",
previousAdvice: "建议启动模型微调或重构评估", commonModel: null,
},
{
bank: "滨海银行", category: "afd", name: "反欺诈评分", modelId: "BH-AFD-001", version: "v1.8",
status: "正常", iteratedAt: "2025-10-30", ranking: "相符", ks: 36.4, psi: 8.1,
ksDrop: 4.2, secondaryHits: 3, wuji: true, processedAt: "2026-05-25",
previousAdvice: "建议重点关注并持续跟踪", commonModel: "反欺诈评分通用版 v1.8",
},
{
bank: "云岭银行", category: "afd", name: "反欺诈评分", modelId: "YL-AFD-001", version: "v1.7",
status: "正常", iteratedAt: "2025-08-19", ranking: "相符", ks: 38.2, psi: 14.6,
ksDrop: 12.7, secondaryHits: 2, wuji: false, processedAt: "2026-04-15",
previousAdvice: "建议持续跟踪,下期复核", commonModel: null,
},
];
export const FEATURE_METRICS = [
{ key: "age", name: "申请人年龄", iv: 0.284, ivDrop: 6.8, csi: 0.041, csiRise: 0.8, ksContribution: -0.7, psiContribution: 1.2 },
{ key: "income", name: "月均收入", iv: 0.251, ivDrop: 12.4, csi: 0.058, csiRise: 1.7, ksContribution: -1.2, psiContribution: 2.4 },
{ key: "debt_ratio", name: "负债收入比", iv: 0.219, ivDrop: 18.6, csi: 0.076, csiRise: 2.9, ksContribution: -2.1, psiContribution: 4.1 },
{ key: "credit_age", name: "信贷账龄", iv: 0.193, ivDrop: 21.3, csi: 0.083, csiRise: 3.5, ksContribution: -2.8, psiContribution: 5.2 },
{ key: "query_3m", name: "近三月查询次数", iv: 0.171, ivDrop: 24.7, csi: 0.091, csiRise: 4.2, ksContribution: -3.4, psiContribution: 6.8 },
] as const;
export const SORTING_DISTRIBUTION = {
bins: ["(0,580]", "[580,600)", "[600,620)", "[620,640)", "[640,660)", "[660,680)", "[680,+)"],
counts: [307, 418, 730, 1100, 1545, 1895, 6104],
badRates: [10.42, 5.98, 5.48, 3.64, 2.85, 1.64, 0.31],
} as const;
export const BANK_AVERAGE_CYCLE: Record<string, number> = {
江城银行: 8.6,
滨海银行: 11.2,
华东银行: 9.8,
南岭银行: 12.4,
云岭银行: 10.1,
通汇银行: 13.6,
北岸银行: 16.0,
};
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> = {
"JC-STD-001": { onlineAt: "2025-03-12", escortStartAt: "2025-01-20", escortEndAt: "2025-03-10", offlineAt: null, developer: "李伟", developmentKs: 46.0, developmentPsi: 3.92, maxLift: 3.24 },
"BH-STD-001": { onlineAt: null, escortStartAt: "2026-07-28", escortEndAt: null, offlineAt: null, developer: "王芳", developmentKs: 47.5, developmentPsi: 3.48, maxLift: 3.37 },
"HD-STD-001": { onlineAt: "2024-11-05", escortStartAt: "2024-09-18", escortEndAt: "2024-11-01", offlineAt: null, developer: "李伟", developmentKs: 46.9, developmentPsi: 3.71, maxLift: 3.18 },
"NL-STD-001": { onlineAt: "2025-08-22", escortStartAt: "2025-06-30", escortEndAt: "2025-08-18", offlineAt: null, developer: "王芳", developmentKs: 45.4, developmentPsi: 4.06, maxLift: 3.09 },
"YL-STD-001": { onlineAt: "2025-05-14", escortStartAt: "2025-03-22", escortEndAt: "2025-05-10", offlineAt: null, developer: "李伟", developmentKs: 48.2, developmentPsi: 3.26, maxLift: 3.42 },
"TH-STD-001": { onlineAt: "2024-06-18", escortStartAt: "2024-04-25", escortEndAt: "2024-06-14", offlineAt: null, developer: "王芳", developmentKs: 44.8, developmentPsi: 4.31, maxLift: 2.96 },
"BA-STD-001": { onlineAt: "2023-09-01", escortStartAt: null, escortEndAt: null, offlineAt: "2026-02-28", developer: "李伟", developmentKs: 42.7, developmentPsi: 4.62, maxLift: 2.81 },
"JC-BIG-001": { onlineAt: "2025-10-08", escortStartAt: "2025-08-15", escortEndAt: "2025-10-05", offlineAt: null, developer: "王芳", developmentKs: 49.1, developmentPsi: 3.05, maxLift: 3.55 },
"HD-BIG-001": { onlineAt: "2025-02-19", escortStartAt: "2024-12-20", escortEndAt: "2025-02-15", offlineAt: null, developer: "李伟", developmentKs: 47.8, developmentPsi: 3.37, maxLift: 3.31 },
"YL-BIG-001": { onlineAt: "2026-08-05", escortStartAt: "2026-06-10", escortEndAt: "2026-08-01", offlineAt: null, developer: "王芳", developmentKs: 46.6, developmentPsi: 3.68, maxLift: 3.12 },
"BH-BIG-001": { onlineAt: "2024-12-03", escortStartAt: "2024-10-11", escortEndAt: "2024-11-29", offlineAt: null, developer: "李伟", developmentKs: 43.9, developmentPsi: 4.25, maxLift: 2.91 },
"NL-BAI-001": { onlineAt: "2025-07-16", escortStartAt: "2025-05-20", escortEndAt: "2025-07-12", offlineAt: null, developer: "王芳", developmentKs: 42.6, developmentPsi: 4.74, maxLift: 2.73 },
"TH-BAI-001": { onlineAt: "2025-04-09", escortStartAt: "2025-02-14", escortEndAt: "2025-04-05", offlineAt: null, developer: "李伟", developmentKs: 43.7, developmentPsi: 4.18, maxLift: 2.88 },
"JC-BAI-001": { onlineAt: "2024-08-27", escortStartAt: "2024-07-01", escortEndAt: "2024-08-23", offlineAt: null, developer: "王芳", developmentKs: 45.1, developmentPsi: 3.89, maxLift: 3.03 },
"HD-AFD-001": { onlineAt: "2025-01-22", escortStartAt: "2024-11-28", escortEndAt: "2025-01-18", offlineAt: null, developer: "李伟", developmentKs: 40.8, developmentPsi: 5.16, maxLift: 2.54 },
"BH-AFD-001": { onlineAt: "2024-10-14", escortStartAt: "2024-08-20", escortEndAt: "2024-10-10", offlineAt: null, developer: "王芳", developmentKs: 42.3, developmentPsi: 4.69, maxLift: 2.68 },
"YL-AFD-001": { onlineAt: "2024-05-08", escortStartAt: "2024-03-15", escortEndAt: "2024-05-04", offlineAt: null, developer: "李伟", developmentKs: 43.5, developmentPsi: 4.42, maxLift: 2.79 },
};
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("、");
}
function hash(value: string): number {
let result = 0;
for (const character of value) result = (result * 31 + character.charCodeAt(0)) >>> 0;
return result;
}
export function modelTrend(
model: ModelRecord,
metric: "ks" | "psi",
months: readonly string[] = MONITOR_MONTHS,
): number[] {
const end = model[metric];
const amplitude = metric === "ks" ? 2.6 : 4.4;
const seed = hash(`${model.modelId}-${metric}`);
const waveAt = (index: number) => (((seed >> (index % 12)) % 9) - 4) * (amplitude / 12);
const lastWave = waveAt(Math.max(0, months.length - 1));
return months.map((_, index) => {
const distance = months.length - 1 - index;
const drift = metric === "ks" ? distance * 0.32 : -distance * 0.74;
const wave = waveAt(index) - lastWave;
return Math.max(0, Number((end + drift + wave).toFixed(2)));
});
}
export function monitoringRows(source: ModelRecord[] = MODELS): MonitoringRow[] {
const rows: MonitoringRow[] = [];
for (const model of source) {
if (model.status === "下线") {
rows.push({ ...model, monitorMonth: "2026-02", ranking: "相符", ks: 39.8, psi: 9.1, ksDrop: 6.2, secondaryHits: 1 });
continue;
}
const ksTrend = modelTrend(model, "ks");
const psiTrend = modelTrend(model, "psi");
MONITOR_MONTHS.forEach((monitorMonth, index) => {
rows.push({
...model,
monitorMonth,
ks: ksTrend[index] ?? model.ks,
psi: psiTrend[index] ?? model.psi,
ksDrop: Math.max(0, Number((model.ksDrop - (MONITOR_MONTHS.length - 1 - index) * 0.9).toFixed(2))),
secondaryHits: Math.max(0, model.secondaryHits - (MONITOR_MONTHS.length - 1 - index)),
});
});
}
return rows.sort((left, right) => (
right.monitorMonth.localeCompare(left.monitorMonth)
|| left.bank.localeCompare(right.bank, "zh-CN")
|| left.modelId.localeCompare(right.modelId)
));
}
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[] {
const models = source.filter((model) => model.category === category && model.status !== "下线");
const series = models.map((model) => modelTrend(model, metric, months));
return months.map((_, index) => Number(average(series.map((values) => values[index] ?? 0)).toFixed(2)));
}
export function bankTrend(
bank: string,
metric: "ks" | "psi",
months: string[],
source: ModelRecord[] = MODELS,
): number[] {
const models = source.filter((model) => model.bank === bank && model.status !== "下线");
const series = models.map((model) => modelTrend(model, metric, months));
return months.map((_, index) => Number(average(series.map((values) => values[index] ?? 0)).toFixed(2)));
}
export function modelsTrend(
models: ModelRecord[],
metric: "ks" | "psi",
months: string[],
): number[] {
const activeModels = models.filter((model) => model.status !== "下线");
const series = activeModels.map((model) => modelTrend(model, metric, months));
return months.map((_, index) => Number(average(series.map((values) => values[index] ?? 0)).toFixed(2)));
}
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 {
const values = models.map(modelIterationCycleMonths).filter((value): value is number => value !== null);
return Number(average(values).toFixed(1));
}
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,85 @@
import { useAuth, type AuthUser } from "~/context/AuthContext";
export type OperationsRole = "business" | "model" | "admin";
export type OperationsPageKey =
| "workbench"
| "usage"
| "models"
| "banks"
| "deployed-models"
| "monitoring"
| "reports"
| "report-summary"
| "workflows"
| "knowledge"
| "rules"
| "prompts"
| "settings";
export type OperationsAction =
| "workflow:create"
| "workflow:advance"
| "workflow:business-confirm"
| "monitor:initial-review"
| "monitor:final-review"
| "report:edit"
| "report:send"
| "rules:manage"
| "prompts:manage"
| "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"],
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:advance": ["model", "admin"],
"workflow:business-confirm": ["business", "admin"],
"monitor:initial-review": ["model", "admin"],
"monitor:final-review": ["business", "admin"],
"report:edit": ["model", "admin"],
"report:send": ["model", "admin"],
"rules:manage": ["admin"],
"prompts:manage": ["model", "admin"],
"settings:manage": ["admin"],
};
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 operationsPageFromPath(pathname: string): OperationsPageKey {
const path = pathname.replace(/^\/operations\/?/, "").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,59 @@
import { MODELS, gradeOf, type ModelGrade } from "./modelData";
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;
};
function reportType(grade: ModelGrade): ReportType {
return grade === "A" ? "监控报告" : "诊断报告";
}
export const REPORTS: MonitoringReport[] = [
{ reportId: "R1", bank: "江城银行", modelName: "标准A卡", modelId: "JC-STD-001", version: "v2.3", monitorMonth: "2026-07", type: "诊断报告", status: "待模型团队阅读", generatedAt: "2026-08-15 06:12", outputDate: "2026-08-15", unreadDays: 4, synced: false },
{ reportId: "R2", bank: "通汇银行", modelName: "标准A卡", modelId: "TH-STD-001", version: "v2.0", monitorMonth: "2026-07", type: "诊断报告", status: "已发送业务团队", generatedAt: "2026-08-15 06:12", outputDate: "2026-08-15", unreadDays: 0, synced: true },
{ reportId: "R3", bank: "华东银行", modelName: "反欺诈评分", modelId: "HD-AFD-001", version: "v2.0", monitorMonth: "2026-07", type: "诊断报告", status: "待模型团队阅读", generatedAt: "2026-08-18 06:16", outputDate: "2026-08-18", unreadDays: 6, synced: false },
{ reportId: "R4", bank: "南岭银行", modelName: "白户A卡", modelId: "NL-BAI-001", version: "v1.5", monitorMonth: "2026-07", type: "诊断报告", status: "编辑中", generatedAt: "2026-08-15 06:12", outputDate: "2026-08-15", unreadDays: 0, synced: true },
{ reportId: "R5", bank: "云岭银行", modelName: "标准A卡", modelId: "YL-STD-001", version: "v2.1", monitorMonth: "2026-07", type: "监控报告", status: "已发送业务团队", generatedAt: "2026-08-20 06:08", outputDate: "2026-08-20", unreadDays: 0, synced: true },
{ reportId: "R6", bank: "华东银行", modelName: "标准A卡", modelId: "HD-STD-001", version: "v2.2", monitorMonth: "2026-07", type: "监控报告", status: "已发送业务团队", generatedAt: "2026-08-18 06:16", outputDate: "2026-08-18", unreadDays: 0, synced: true },
{ reportId: "H1", bank: "江城银行", modelName: "标准A卡", modelId: "JC-STD-001", version: "v2.3", monitorMonth: "2026-06", type: "诊断报告", status: "已发送业务团队", generatedAt: "2026-07-15 06:11", outputDate: "2026-07-15", unreadDays: 0, synced: true },
{ reportId: "H2", bank: "南岭银行", modelName: "标准A卡", modelId: "NL-STD-001", version: "v1.6", monitorMonth: "2026-06", type: "诊断报告", status: "已发送业务团队", generatedAt: "2026-07-15 06:11", outputDate: "2026-07-15", unreadDays: 0, synced: true },
{ reportId: "H3", bank: "滨海银行", modelName: "大额A卡", modelId: "BH-BIG-001", version: "v1.1", monitorMonth: "2026-05", type: "诊断报告", status: "已发送业务团队", generatedAt: "2026-06-15 06:09", outputDate: "2026-06-15", unreadDays: 0, synced: true },
];
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 {
const model = MODELS.find((item) => item.modelId === modelId);
if (!model) return null;
return {
reportId: `AUTO-${model.modelId}`,
bank: model.bank,
modelName: model.name,
modelId: model.modelId,
version: model.version,
monitorMonth: "2026-07",
type: reportType(gradeOf(model)),
status: "待模型团队阅读",
generatedAt: "2026-08-15 06:12",
outputDate: "2026-08-15",
unreadDays: 0,
synced: false,
};
}
@@ -0,0 +1,19 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { REPORTS, type MonitoringReport } from "./reportData";
type ReportStore = {
reports: MonitoringReport[];
updateReport: (reportId: string, updates: Partial<MonitoringReport>) => void;
};
export const useReportStore = create<ReportStore>()(persist((set) => ({
reports: REPORTS,
updateReport: (reportId, updates) => set((state) => ({
reports: state.reports.map((report) => report.reportId === reportId ? { ...report, ...updates } : report),
})),
}), {
name: "a-card-operations-report-mock",
version: 1,
}));
@@ -0,0 +1,144 @@
import { MODELS, categoryName, 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[] = [
{
workflowId: "F1", title: "南岭银行 · 标准A卡 迭代", bank: "南岭银行", category: "std", modelId: "NL-STD-001",
initiatedBy: "业务团队 张明", initiatedAt: "2026-07-28", currentStage: 4, deadline: "2026-08-08", stalledDays: 12,
files: {
2: [{ name: "NL标准A卡_模型设计方案_v2.docx", uploadedBy: "模型团队 李伟", uploadedAt: "2026-08-04", confirmed: true }],
3: [{ name: "开发结果材料_入模变量与效果.xlsx", uploadedBy: "模型团队 李伟", uploadedAt: "2026-08-14", confirmed: false }],
},
},
{
workflowId: "F2", title: "滨海银行 · 新增标准A卡", bank: "滨海银行", category: "std", modelId: "BH-STD-001",
initiatedBy: "业务团队 张明", initiatedAt: "2026-06-12", currentStage: 6, plannedTestAt: "2026-07-30",
files: {
2: [{ name: "BH标准A卡_设计方案.docx", uploadedBy: "模型团队 王芳", uploadedAt: "2026-06-20", confirmed: true }],
3: [{ name: "开发结果_变量清单.xlsx", uploadedBy: "模型团队 王芳", uploadedAt: "2026-07-08", confirmed: true }],
4: [{ name: "新老模型对比数据.xlsx", uploadedBy: "模型团队 王芳", uploadedAt: "2026-07-15", confirmed: true }],
5: [
{ name: "评审会议纪要_20260722.docx", uploadedBy: "模型团队 王芳", uploadedAt: "2026-07-23", confirmed: true },
{ name: "最终评审材料.pptx", uploadedBy: "模型团队 王芳", uploadedAt: "2026-07-23", confirmed: true },
],
6: [
{ name: "模型测试文件.zip", uploadedBy: "模型团队 王芳", uploadedAt: "2026-07-28", confirmed: false },
{ name: "一致性报告.pdf", uploadedBy: "模型团队 王芳", uploadedAt: "2026-07-28", confirmed: false },
],
},
},
{
workflowId: "F3", title: "华东银行 · 反欺诈评分 重构", bank: "华东银行", category: "afd", modelId: "HD-AFD-001",
initiatedBy: "业务团队 陈静", initiatedAt: "2026-08-11", currentStage: 1, files: {},
},
{
workflowId: "H1", title: "华东银行 · 标准A卡 新增(复用通用版)", bank: "华东银行", category: "std", modelId: "HD-STD-001",
initiatedBy: "业务团队 张明", initiatedAt: "2026-03-04", currentStage: 8, completedAt: "2026-04-02", reusedModel: "标准A卡通用版 v2.3", plannedTestAt: "2026-03-18", plannedOnlineAt: "2026-04-02", files: {},
},
{
workflowId: "H2", title: "云岭银行 · 标准A卡 新增(复用通用版)", bank: "云岭银行", category: "std", modelId: "YL-STD-001",
initiatedBy: "业务团队 张明", initiatedAt: "2026-01-06", currentStage: 8, completedAt: "2026-02-05", reusedModel: "标准A卡通用版 v2.3", plannedTestAt: "2026-01-20", plannedOnlineAt: "2026-02-05", files: {},
},
{
workflowId: "H3", title: "江城银行 · 大额A卡 新增", bank: "江城银行", category: "big", modelId: "JC-BIG-001",
initiatedBy: "业务团队 张明", initiatedAt: "2025-11-12", currentStage: 8, completedAt: "2026-01-08", plannedTestAt: "2025-12-15", plannedOnlineAt: "2026-01-08", files: {},
},
{
workflowId: "H4", title: "南岭银行 · 白户A卡 迭代", bank: "南岭银行", category: "bai", modelId: "NL-BAI-001",
initiatedBy: "业务团队 陈静", initiatedAt: "2026-04-02", currentStage: 8, completedAt: "2026-05-22", plannedTestAt: "2026-04-28", plannedOnlineAt: "2026-05-22", files: {},
},
{
workflowId: "H5", title: "通汇银行 · 白户A卡 新增(复用通用版)", bank: "通汇银行", category: "bai", modelId: "TH-BAI-001",
initiatedBy: "业务团队 张明", initiatedAt: "2026-01-15", currentStage: 8, completedAt: "2026-02-11", reusedModel: "白户A卡通用版 v1.4", plannedTestAt: "2026-02-01", plannedOnlineAt: "2026-02-11", files: {},
},
];
export const USAGE_RECORDS: UsageRecord[] = [
{ person: "张明", team: "业务团队", logins: 42, requests: 6, reportsRead: 18, reportsDownloaded: 11, lastLoginAt: "2026-08-21" },
{ person: "陈静", team: "业务团队", logins: 9, requests: 1, reportsRead: 4, reportsDownloaded: 1, lastLoginAt: "2026-08-11" },
{ person: "周伟", team: "业务团队", logins: 3, requests: 0, reportsRead: 1, reportsDownloaded: 0, lastLoginAt: "2026-07-30" },
{ person: "李伟", team: "模型团队", logins: 88, requests: 0, reportsRead: 36, reportsDownloaded: 24, lastLoginAt: "2026-08-21" },
{ person: "王芳", team: "模型团队", logins: 76, requests: 0, reportsRead: 31, reportsDownloaded: 19, lastLoginAt: "2026-08-20" },
{ person: "朱瑞", team: "模型团队", logins: 21, requests: 0, reportsRead: 9, reportsDownloaded: 5, lastLoginAt: "2026-08-14" },
{ person: "王芳", team: "管理员", logins: 76, requests: 0, reportsRead: 31, reportsDownloaded: 19, lastLoginAt: "2026-08-20" },
];
export function workflowDocuments(): KnowledgeDocument[] {
return WORKFLOWS.flatMap((workflow) => Object.entries(workflow.files).flatMap(([stage, files]) => {
const model = MODELS.find((item) => item.modelId === workflow.modelId);
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: categoryName(workflow.category),
modelVersion: model?.version ?? "—",
modelId: workflow.modelId,
stage: stageName,
commonModel: Boolean(model?.commonModel),
}));
}));
}
@@ -5,7 +5,7 @@ import {
useScriptWorkspaceStore,
} from "../state/scriptWorkspaceStore";
type ActivePage = "home" | "scripts" | "schedules" | "system";
type ActivePage = "home" | "scripts" | "schedules" | "operations" | "system";
/**
* 必须在 layout 层挂载,不能放在 ScriptsPage。
@@ -0,0 +1,24 @@
import { type Dispatch, type SetStateAction, useEffect, useState } from "react";
export function usePersistentState<T>(key: string, initialValue: T | (() => T)): [T, Dispatch<SetStateAction<T>>] {
const [value, setValue] = useState<T>(() => {
const fallback = typeof initialValue === "function" ? (initialValue as () => T)() : initialValue;
if (typeof window === "undefined") return fallback;
try {
const stored = window.localStorage.getItem(key);
return stored ? JSON.parse(stored) as T : fallback;
} catch {
return fallback;
}
});
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
// Storage can be unavailable in private mode; in-memory state still works.
}
}, [key, value]);
return [value, setValue];
}
+35
View File
@@ -0,0 +1,35 @@
export type ExcelValue = string | number | boolean | Date | null | undefined;
export type ExcelExportOptions = {
fileName: string;
sheetName: string;
headers: string[];
rows: ExcelValue[][];
};
function safeFileName(value: string): string {
return value.replace(/[\\/:*?"<>|]/g, "_").replace(/\.xlsx$/i, "");
}
function safeSheetName(value: string): string {
return value.replace(/[\\/*?:\[\]]/g, "_").slice(0, 31) || "Sheet1";
}
export async function exportRowsToExcel({ fileName, sheetName, headers, rows }: ExcelExportOptions): Promise<void> {
const { default: writeExcelFile } = await import("write-excel-file/browser");
const headerRow = headers.map((value) => ({
value,
fontWeight: "bold" as const,
backgroundColor: "#EDF6FF",
align: "center" as const,
}));
const columns = headers.map((header, index) => {
const maxLength = Math.max(header.length * 2, ...rows.map((row) => String(row[index] ?? "").length));
return { width: Math.min(42, Math.max(12, maxLength + 3)) };
});
await writeExcelFile([headerRow, ...rows], {
sheet: safeSheetName(sheetName),
stickyRowsCount: 1,
columns,
}).toFile(`${safeFileName(fileName)}.xlsx`);
}
+16
View File
@@ -7,6 +7,22 @@ export default [
route("workbench", "features/platform/DashboardRoute.tsx"),
route("scripts", "features/platform/ScriptsPage.tsx"),
route("schedules", "features/schedules/SchedulesPageRoute.tsx"),
route("operations", "routes/operations.tsx", [
index("features/operations/OperationsWorkbenchPage.tsx"),
route("usage", "features/operations/UsageStatsPage.tsx"),
route("models", "features/operations/ModelOverviewPage.tsx"),
route("banks", "features/operations/BankOverviewPage.tsx"),
route("deployed-models", "features/operations/DeployedModelsPage.tsx"),
route("monitoring", "features/operations/MonitoringOverviewPage.tsx"),
route("monitoring/:modelId", "features/operations/MonitoringDetailPage.tsx"),
route("reports", "features/operations/ReportPage.tsx"),
route("report-summary", "features/operations/ReportSummaryPage.tsx"),
route("workflows", "features/operations/WorkflowPage.tsx"),
route("knowledge", "features/operations/KnowledgeBasePage.tsx"),
route("rules", "features/operations/RuleManagementPage.tsx"),
route("prompts", "features/operations/PromptManagementPage.tsx"),
route("settings", "features/operations/SystemConfigPage.tsx"),
]),
route("system", "routes/system.tsx", [
index("routes/system-index.tsx"),
route("users", "routes/system-users.tsx"),
+41
View File
@@ -0,0 +1,41 @@
import { LockKeyhole } from "lucide-react";
import { Outlet, useLocation, useNavigate } from "react-router";
import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card";
import { OperationsDataBoundary, OperationsDataProvider } from "~/features/operations/OperationsDataContext";
import {
isOperationsPageVisibleForRole,
operationsPageFromPath,
useOperationsRole,
} from "~/features/operations/operationsRole";
export default function OperationsLayout() {
const role = useOperationsRole();
const location = useLocation();
const navigate = useNavigate();
const page = operationsPageFromPath(location.pathname);
if (!isOperationsPageVisibleForRole(role, page)) {
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-warning-soft text-warning"><LockKeyhole className="size-6" /></span>
<h2 className="mt-4 text-xl font-bold text-foreground"></h2>
<p className="mt-2 text-sm leading-6 text-muted-foreground"></p>
<Button className="mt-5" onClick={() => navigate("/operations")}></Button>
</CardContent>
</Card>
</section>
);
}
return (
<OperationsDataProvider>
<OperationsDataBoundary>
<Outlet />
</OperationsDataBoundary>
</OperationsDataProvider>
);
}
+86 -5
View File
@@ -1,8 +1,45 @@
import { Home, Code, Calendar, Settings, Users, Folder, ShieldCheck } from "lucide-react";
import {
Activity,
Building2,
Calendar,
ChartNoAxesColumn,
ClipboardList,
Code,
BookOpen,
FileText,
Files,
Folder,
Gauge,
GitBranch,
Home,
Layers3,
ListFilter,
MessageSquareText,
Settings,
ShieldCheck,
Users,
Wrench,
} from "lucide-react";
import type { ComponentType } from "react";
import type { OperationsRole } from "~/features/operations/operationsRole";
export type ActivePage = "home" | "scripts" | "schedules" | "system";
export type SubPage = "users" | "projects" | "roles";
export type ActivePage = "home" | "scripts" | "schedules" | "operations" | "system";
export type SubPage =
| "users"
| "projects"
| "roles"
| "usage"
| "models"
| "banks"
| "deployed-models"
| "monitoring"
| "reports"
| "report-summary"
| "workflows"
| "knowledge"
| "rules"
| "prompts"
| "settings";
export type NavigationItem = {
/** 菜单显示名 */
@@ -15,25 +52,69 @@ export type NavigationItem = {
sub?: SubPage;
/** 命中高亮的 pathname;不填则按 pathForPage(page) 派生 */
activePath?: string;
/** 子菜单点击后直达的路径;用于跨域工作台入口 */
targetPath?: string;
/** 当前端可访问的权限码;未配置时视为所有人可访问 */
permission?: string;
/** 运维域登录角色适配,仅用于界面展示,不承担权限管理 */
visibleToOperationsRoles?: OperationsRole[];
/** 子菜单条目 */
children?: NavigationItem[];
};
export const navigation: NavigationItem[] = [
{ label: "工作台", icon: Home, page: "home", permission: "dashboard:view" },
{
label: "工作台",
icon: Home,
page: "home",
children: [
{ label: "开发工作台", icon: Home, page: "home", activePath: "/workbench", targetPath: "/workbench" },
{ label: "运维工作台", icon: Gauge, page: "operations", activePath: "/operations", targetPath: "/operations", visibleToOperationsRoles: ["business", "model", "admin"] },
{ label: "平台使用统计", icon: ChartNoAxesColumn, page: "operations", activePath: "/operations/usage", targetPath: "/operations/usage", visibleToOperationsRoles: ["admin"] },
],
},
{ label: "构建脚本", icon: Code, page: "scripts", permission: "script:view" },
{ label: "调度配置", icon: Calendar, page: "schedules", permission: "schedule:view" },
{
label: "上线管理域",
icon: Layers3,
page: "operations",
children: [
{ label: "模型大类概览", icon: Layers3, page: "operations", sub: "models", activePath: "/operations/models", visibleToOperationsRoles: ["business", "model", "admin"] },
{ label: "细分银行概览", icon: Building2, page: "operations", sub: "banks", activePath: "/operations/banks", visibleToOperationsRoles: ["business", "model", "admin"] },
{ label: "已上线模型详情", icon: ClipboardList, page: "operations", sub: "deployed-models", activePath: "/operations/deployed-models", visibleToOperationsRoles: ["model", "admin"] },
],
},
{
label: "监控运维域",
icon: Activity,
page: "operations",
children: [
{ label: "监控明细", icon: ListFilter, page: "operations", sub: "monitoring", activePath: "/operations/monitoring", visibleToOperationsRoles: ["business", "model", "admin"] },
{ label: "监控诊断报告", icon: FileText, page: "operations", sub: "reports", activePath: "/operations/reports", visibleToOperationsRoles: ["business", "model", "admin"] },
{ label: "历史报告汇总", icon: Files, page: "operations", sub: "report-summary", activePath: "/operations/report-summary", visibleToOperationsRoles: ["business", "model", "admin"] },
],
},
{
label: "开发评审域",
icon: GitBranch,
page: "operations",
children: [
{ label: "全流程进度", icon: GitBranch, page: "operations", sub: "workflows", activePath: "/operations/workflows", visibleToOperationsRoles: ["business", "model", "admin"] },
{ label: "文档知识库", icon: BookOpen, page: "operations", sub: "knowledge", activePath: "/operations/knowledge", visibleToOperationsRoles: ["model", "admin"] },
],
},
{
label: "系统管理",
icon: Settings,
page: "system",
permission: "system:view",
children: [
{ label: "用户管理", icon: Users, page: "system", sub: "users", permission: "system:user:view" },
{ label: "项目管理", icon: Folder, page: "system", sub: "projects", permission: "system:project:view" },
{ label: "角色管理", icon: ShieldCheck, page: "system", sub: "roles", permission: "system:role:view" },
{ label: "监控等级规则", icon: ShieldCheck, page: "operations", sub: "rules", activePath: "/operations/rules", visibleToOperationsRoles: ["model", "admin"] },
{ label: "报告 Prompt 管理", icon: MessageSquareText, page: "operations", sub: "prompts", activePath: "/operations/prompts", visibleToOperationsRoles: ["model", "admin"] },
{ label: "运维系统配置", icon: Wrench, page: "operations", sub: "settings", activePath: "/operations/settings", visibleToOperationsRoles: ["admin"] },
],
},
];
+63 -25
View File
@@ -1,9 +1,9 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Outlet, useLocation, useNavigate } from "react-router";
import { PanelLeft, ChevronRight } from "lucide-react";
import { navigation } from "./platform.navigation";
import type { ActivePage, NavigationItem, SubPage } from "./platform.navigation";
import type { ActivePage, NavigationItem } from "./platform.navigation";
import { Collapsible } from "@base-ui/react/collapsible";
import type { Route } from "./+types/platform";
@@ -24,7 +24,7 @@ import {
SidebarTrigger,
SidebarInset,
} from "~/components/ui/sidebar";
import { useApi, useAuth, usePermission } from "~/context/AuthContext";
import { useApi, useAuth, usePermission, type AuthWorkspace } from "~/context/AuthContext";
import { useEditSessionLifecycle } from "~/features/platform/hooks/useEditSessionLifecycle";
import {
bindScriptWorkspaceApi,
@@ -36,11 +36,12 @@ import {
import { useUiStore } from "~/features/platform/state/uiStore";
import { bindAdminApi } from "~/features/admin/state/adminStore";
import { bindSchedulesApi } from "~/features/schedules/state/helpers";
import { resolveOperationsRole, type OperationsRole } from "~/features/operations/operationsRole";
function pageFromPath(pathname: string): ActivePage {
const first = pathname.replace(/^\/+/, "").split("/")[0];
if (first === "workbench" || first === "") return "home";
if (first === "scripts" || first === "schedules" || first === "system") {
if (first === "scripts" || first === "schedules" || first === "operations" || first === "system") {
return first as ActivePage;
}
return "home";
@@ -51,6 +52,14 @@ function pathForPage(page: ActivePage): string {
return `/${page}`;
}
const OPERATIONS_GLOBAL_WORKSPACE: AuthWorkspace = {
workspace_id: "operations-global",
workspace_code: "operations-global",
workspace_name: "运维全局视图",
role_code: "viewer",
role_name: "运维用户",
};
export function meta({}: Route.MetaArgs) {
return [
{ title: "模型实验开发平台" },
@@ -64,21 +73,30 @@ function NavRow({
activePage,
pathname,
onNavigate,
operationsRole,
}: {
item: NavigationItem;
can: (code: string) => boolean;
activePage: ActivePage;
pathname: string;
onNavigate: (sub?: SubPage) => void;
onNavigate: (targetPath: string) => void;
operationsRole: OperationsRole;
}) {
const itemIcon = <item.icon />;
const childActivePath = (child: NavigationItem) =>
child.activePath ?? pathForPage(child.page);
const childIsActive = (child: NavigationItem) => {
const activePath = childActivePath(child);
return pathname === activePath || Boolean(child.sub && pathname.startsWith(`${activePath}/`));
};
const childTargetPath = (child: NavigationItem) =>
child.targetPath ?? child.activePath ?? (child.sub ? `${pathForPage(child.page)}/${child.sub}` : pathForPage(child.page));
const groupIsActive = Boolean(item.children?.some(childIsActive));
if (item.children && item.children.length > 0) {
return (
<Collapsible.Root
defaultOpen={activePage === item.page}
defaultOpen={groupIsActive}
className="group/collapsible"
>
<SidebarMenuItem>
@@ -86,8 +104,8 @@ function NavRow({
render={
<SidebarMenuButton
className="py-3 text-sm"
isActive={activePage === item.page}
data-active={activePage === item.page ? "true" : undefined}
isActive={groupIsActive}
data-active={groupIsActive ? "true" : undefined}
/>
}
>
@@ -98,13 +116,14 @@ function NavRow({
<Collapsible.Panel>
<SidebarMenuSub className="gap-1.5 mt-2">
{item.children
.filter((child) => !child.permission || can(child.permission))
.filter((child) => (!child.permission || can(child.permission))
&& (!child.visibleToOperationsRoles || child.visibleToOperationsRoles.includes(operationsRole)))
.map((child) => (
<SidebarMenuSubItem key={child.label}>
<SidebarMenuSubButton
className="h-9 text-xs"
onClick={() => onNavigate(child.sub)}
isActive={pathname === childActivePath(child)}
onClick={() => onNavigate(childTargetPath(child))}
isActive={childIsActive(child)}
>
<child.icon /> <span>{child.label}</span>
</SidebarMenuSubButton>
@@ -121,7 +140,7 @@ function NavRow({
<SidebarMenuItem>
<SidebarMenuButton
className="py-3 text-sm"
onClick={() => onNavigate()}
onClick={() => onNavigate(pathForPage(item.page))}
isActive={activePage === item.page}
data-active={activePage === item.page ? "true" : undefined}
>
@@ -134,7 +153,8 @@ function NavRow({
export default function PlatformLayout() {
const { currentWorkspace } = useAuth();
if (!currentWorkspace) {
const location = useLocation();
if (!currentWorkspace && !location.pathname.startsWith("/operations")) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#eef2f6]">
<div className="text-center">
@@ -168,8 +188,12 @@ function AuthenticatedLayout() {
const setWorkspaceMenuOpen = useUiStore((s) => s.setWorkspaceMenuOpen);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const bindingGeneration = useRef(0);
const can = usePermission;
const operationsRole = resolveOperationsRole(user);
const effectiveWorkspace = currentWorkspace ?? OPERATIONS_GLOBAL_WORKSPACE;
const effectiveWorkspaces = currentWorkspace ? workspaces : [OPERATIONS_GLOBAL_WORKSPACE];
// 绑定 api 到 script workspace store。
// 必须放在 render body(同步),不能用 useEffect([api]) + cleanup —
@@ -184,13 +208,18 @@ function AuthenticatedLayout() {
bindScriptWorkspaceUser(user?.user_id ?? null);
bindScriptWorkspaceId(currentWorkspace?.workspace_id ?? null);
useEffect(() => {
return () => {
const generation = ++bindingGeneration.current;
return () => queueMicrotask(() => {
// React Strict Mode runs effect cleanup/setup once during development.
// Only clear bindings when this layout really unmounts, not during that
// rehearsal, otherwise child data effects see "API 未绑定".
if (bindingGeneration.current !== generation) return;
bindScriptWorkspaceApi(null);
bindSchedulesApi(null);
bindAdminApi(null);
bindScriptWorkspaceUser(null);
bindScriptWorkspaceId(null);
};
});
}, []);
// 心跳 / cleanup / 切页结束编辑 / 卸载前释放
@@ -198,16 +227,24 @@ function AuthenticatedLayout() {
// 原 common/Sidebar 的编辑会话守卫上移到这里:切出 /scripts 时有活动编辑
// 会话先 endEditing,回工作台时清空选中脚本;再按当前激活页导航。
function guardedNavigate(page: ActivePage, sub?: SubPage) {
if (page !== "scripts" && editSessionHandle.current) {
function guardedNavigate(targetPath: string) {
const targetPage = pageFromPath(targetPath);
if (targetPage !== "scripts" && editSessionHandle.current) {
void endEditing(true);
} else if (page === "home") {
} else if (targetPage === "home") {
selectScript(null);
}
const base = pathForPage(page);
navigate(sub ? `${base}/${sub}` : base);
navigate(targetPath);
}
const canShowNavItem = (item: NavigationItem) => (
(!item.permission || can(item.permission))
&& (!item.children || item.children.some((child) => (
(!child.permission || can(child.permission))
&& (!child.visibleToOperationsRoles || child.visibleToOperationsRoles.includes(operationsRole))
)))
);
return (
<SidebarProvider
defaultOpen={!sidebarCollapsed}
@@ -233,15 +270,16 @@ function AuthenticatedLayout() {
<SidebarContent>
<SidebarMenu className="gap-1.5">
{navigation
.filter((item) => !item.permission || can(item.permission))
.filter(canShowNavItem)
.map((item) => (
<NavRow
key={item.page}
key={item.label}
item={item}
can={can}
activePage={activePage}
pathname={location.pathname}
onNavigate={(sub) => guardedNavigate(item.page, sub)}
onNavigate={guardedNavigate}
operationsRole={operationsRole}
/>
))}
</SidebarMenu>
@@ -258,8 +296,8 @@ function AuthenticatedLayout() {
activePage={activePage}
apiOnline={apiOnline}
user={user}
currentWorkspace={currentWorkspace!}
workspaces={workspaces}
currentWorkspace={effectiveWorkspace}
workspaces={effectiveWorkspaces}
workspaceMenuOpen={workspaceMenuOpen}
onSetWorkspaceMenuOpen={setWorkspaceMenuOpen}
onSetCurrentWorkspace={setCurrentWorkspace}
+199
View File
@@ -0,0 +1,199 @@
import {
MODELS,
monitoringRows,
type ModelCategoryId,
type ModelRecord,
type ModelStatus,
type MonitoringRow,
type RankingStatus,
} from "~/features/operations/modelData";
export type OperationsApiMode = "mock" | "api";
export type OperationsModelListParams = {
workspaceId?: string;
bank?: string;
category?: ModelCategoryId;
status?: ModelStatus;
keyword?: string;
signal?: AbortSignal;
};
export type OperationsModelDto = {
model_instance_id: string;
bank_name: string;
model_category: ModelCategoryId;
model_name: string;
model_id: string;
model_version: string;
model_status: ModelStatus;
last_iteration_date: string;
ranking_result: RankingStatus;
ks: number;
psi: number;
ks_mom_drop: number;
secondary_hits_6m: number;
is_wuji_bank: boolean;
last_processed_at: string | null;
previous_advice: string;
common_model_name: string | null;
};
export type MonthlyMonitoringResultDto = {
model_instance_id: string;
monitor_month: string;
ranking_result: RankingStatus;
ks: number;
psi: number;
ks_mom_drop: number;
secondary_hits_6m: number;
};
type ApiEnvelope<T> = {
data: T;
meta?: Record<string, unknown>;
};
export class OperationsApiError extends Error {
readonly status: number;
readonly code?: string;
constructor(message: string, status: number, code?: string) {
super(message);
this.name = "OperationsApiError";
this.status = status;
this.code = code;
}
}
const configuredMode = import.meta.env.VITE_OPERATIONS_API_MODE;
export const operationsApiMode: OperationsApiMode = configuredMode === "api" ? "api" : "mock";
const API_BASE = "/api/v1/operations";
function requireWorkspaceId(workspaceId?: string): string {
if (workspaceId) return workspaceId;
throw new OperationsApiError("请先选择项目空间", 400, "WORKSPACE_REQUIRED");
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
...init,
credentials: "same-origin",
headers: {
Accept: "application/json",
...init.headers,
},
});
const payload = await response.json().catch(() => ({})) as ApiEnvelope<T> & {
detail?: string | { code?: string; message?: string };
};
if (!response.ok) {
const message = typeof payload.detail === "string"
? payload.detail
: payload.detail?.message ?? `请求失败(HTTP ${response.status}`;
throw new OperationsApiError(message, response.status, typeof payload.detail === "object" ? payload.detail.code : undefined);
}
return payload.data;
}
function toModelRecord(dto: OperationsModelDto): ModelRecord {
return {
bank: dto.bank_name,
category: dto.model_category,
name: dto.model_name,
modelId: dto.model_id,
version: dto.model_version,
status: dto.model_status,
iteratedAt: dto.last_iteration_date,
ranking: dto.ranking_result,
ks: dto.ks,
psi: dto.psi,
ksDrop: dto.ks_mom_drop,
secondaryHits: dto.secondary_hits_6m,
wuji: dto.is_wuji_bank,
processedAt: dto.last_processed_at,
previousAdvice: dto.previous_advice,
commonModel: dto.common_model_name,
};
}
function mockDelay(signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const timer = window.setTimeout(resolve, 160);
signal?.addEventListener("abort", () => {
window.clearTimeout(timer);
reject(new DOMException("Request aborted", "AbortError"));
}, { once: true });
});
}
export async function listOperationsModels(params: OperationsModelListParams = {}): Promise<ModelRecord[]> {
if (operationsApiMode === "mock") {
await mockDelay(params.signal);
const keyword = params.keyword?.trim().toLowerCase();
return MODELS
.filter((model) => !params.bank || model.bank === params.bank)
.filter((model) => !params.category || model.category === params.category)
.filter((model) => !params.status || model.status === params.status)
.filter((model) => !keyword || `${model.bank} ${model.name} ${model.modelId} ${model.version}`.toLowerCase().includes(keyword))
.map((model) => ({ ...model }));
}
const query = new URLSearchParams();
query.set("workspace_id", requireWorkspaceId(params.workspaceId));
if (params.bank) query.set("bank", params.bank);
if (params.category) query.set("category", params.category);
if (params.status) query.set("status", params.status);
if (params.keyword) query.set("keyword", params.keyword);
const suffix = query.size ? `?${query.toString()}` : "";
const data = await request<OperationsModelDto[]>(`/models${suffix}`, { signal: params.signal });
return data.map(toModelRecord);
}
export async function getOperationsModel(
modelId: string,
workspaceId?: string,
signal?: AbortSignal,
): Promise<ModelRecord> {
if (operationsApiMode === "mock") {
await mockDelay(signal);
const model = MODELS.find((item) => item.modelId === modelId);
if (!model) throw new OperationsApiError("模型不存在", 404, "MODEL_NOT_FOUND");
return { ...model };
}
const query = new URLSearchParams({ workspace_id: requireWorkspaceId(workspaceId) });
const data = await request<OperationsModelDto>(`/models/${encodeURIComponent(modelId)}?${query.toString()}`, { signal });
return toModelRecord(data);
}
export async function getMonthlyMonitoringResult(
modelId: string,
month: string,
workspaceId?: string,
signal?: AbortSignal,
): Promise<MonitoringRow> {
if (operationsApiMode === "mock") {
await mockDelay(signal);
const result = monitoringRows(MODELS).find((item) => item.modelId === modelId && item.monitorMonth === month);
if (!result) throw new OperationsApiError("该月份暂无监控结果", 404, "MONITOR_RESULT_NOT_FOUND");
return { ...result };
}
const model = await getOperationsModel(modelId, workspaceId, signal);
const query = new URLSearchParams({
month,
workspace_id: requireWorkspaceId(workspaceId),
});
const data = await request<MonthlyMonitoringResultDto>(
`/models/${encodeURIComponent(modelId)}/monitor-results?${query.toString()}`,
{ signal },
);
return {
...model,
monitorMonth: data.monitor_month,
ranking: data.ranking_result,
ks: data.ks,
psi: data.psi,
ksDrop: data.ks_mom_drop,
secondaryHits: data.secondary_hits_6m,
};
}
+1
View File
@@ -31,6 +31,7 @@
"sonner": "^2.0.8",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
"write-excel-file": "4.1.1",
"zustand": "^5.0.14"
},
"devDependencies": {
+19 -34
View File
@@ -65,6 +65,9 @@ importers:
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
write-excel-file:
specifier: 4.1.1
version: 4.1.1
zustand:
specifier: ^5.0.14
version: 5.0.14(@types/react@19.2.17)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))
@@ -299,10 +302,6 @@ packages:
'@jridgewell/gen-mapping@0.3.12':
resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==}
'@jridgewell/gen-mapping@0.3.5':
resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==}
engines: {node: '>=6.0.0'}
'@jridgewell/remapping@2.3.5':
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
@@ -310,19 +309,6 @@ packages:
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/set-array@1.2.1':
resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
engines: {node: '>=6.0.0'}
'@jridgewell/sourcemap-codec@1.4.10':
resolution: {integrity: sha512-Ht8wIW5v165atIX1p+JvKR5ONzUyF4Ac8DZIQ5kZs9zrb6M8SJNXpx1zn04rn65VjBMygRoMXcyYwNK0fT7bEg==}
'@jridgewell/sourcemap-codec@1.4.14':
resolution: {integrity: sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==}
'@jridgewell/sourcemap-codec@1.5.0':
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
@@ -1062,6 +1048,9 @@ packages:
picomatch:
optional: true
fflate@0.8.3:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
figures@6.1.0:
resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
engines: {node: '>=18'}
@@ -2131,6 +2120,10 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
write-excel-file@4.1.1:
resolution: {integrity: sha512-MUnCnNtQrcZek832ZcU24uU0rSphFmKPD1DvIjXOlygVb93CV7Tme6H3jUTkxsMmjB2W7HIzERzjqTi5kui71A==}
engines: {node: '>=18'}
wsl-utils@1.0.0:
resolution: {integrity: sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA==}
engines: {node: '>=20'}
@@ -2447,36 +2440,22 @@ snapshots:
'@jridgewell/gen-mapping@0.3.12':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.0
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/gen-mapping@0.3.5':
dependencies:
'@jridgewell/set-array': 1.2.1
'@jridgewell/sourcemap-codec': 1.4.10
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/remapping@2.3.5':
dependencies:
'@jridgewell/gen-mapping': 0.3.5
'@jridgewell/gen-mapping': 0.3.12
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/set-array@1.2.1': {}
'@jridgewell/sourcemap-codec@1.4.10': {}
'@jridgewell/sourcemap-codec@1.4.14': {}
'@jridgewell/sourcemap-codec@1.5.0': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.4.14
'@jridgewell/sourcemap-codec': 1.5.5
'@modelcontextprotocol/sdk@1.30.0(zod@3.24.1)':
dependencies:
@@ -3157,6 +3136,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.5
fflate@0.8.3: {}
figures@6.1.0:
dependencies:
is-unicode-supported: 2.1.0
@@ -4063,6 +4044,10 @@ snapshots:
wrappy@1.0.2: {}
write-excel-file@4.1.1:
dependencies:
fflate: 0.8.3
wsl-utils@1.0.0:
dependencies:
is-wsl: 3.1.1
+27 -22
View File
@@ -1,29 +1,34 @@
import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
import { defineConfig, loadEnv } from "vite";
import path from "node:path";
export default defineConfig({
base: '/',
plugins: [reactRouter(), tailwindcss()],
resolve: {
alias: {
"~": path.resolve(__dirname, "./app"),
},
},
server: {
host: "0.0.0.0",
port: 5173,
proxy: {
"/api": {
target: process.env.VITE_GATEWAY_URL || "http://127.0.0.1:8890",
changeOrigin: true,
},
"/jupyter": {
target: process.env.VITE_GATEWAY_URL || "http://127.0.0.1:8890",
changeOrigin: true,
ws: true,
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, __dirname, "");
const gatewayUrl = env.VITE_GATEWAY_URL || "http://127.0.0.1:8890";
return {
base: '/',
plugins: [reactRouter(), tailwindcss()],
resolve: {
alias: {
"~": path.resolve(__dirname, "./app"),
},
},
},
server: {
host: "0.0.0.0",
port: 5173,
proxy: {
"/api": {
target: gatewayUrl,
changeOrigin: true,
},
"/jupyter": {
target: gatewayUrl,
changeOrigin: true,
ws: true,
},
},
},
};
});