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
@@ -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>
);
}