feat: 接入运维真实数据与三角色权限链路

- 新增 model_deploy 与 model_operations 双库查询,支持模型列表、模型详情、单月监控结果和运维工作台真实接口。

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

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

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

- 新增架构实现基线、周目标完成情况和角色权限矩阵初始化 SQL 文档。
This commit is contained in:
郑龙捷
2026-09-02 15:02:47 +08:00
parent 6d044784ec
commit 74dd97428f
51 changed files with 2142 additions and 1062 deletions
@@ -1,224 +1,143 @@
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 type { OperationsWorkbenchItem, OperationsWorkbenchKpi } from "~/services/operationsApi";
import { useOperationsData } from "./OperationsDataContext";
import { MODEL_CATEGORIES, categoryName, gradeOf, type ModelGrade } from "./modelData";
const FALLBACK_KPIS: OperationsWorkbenchKpi[] = [
{ key: "models", label: "在管模型", value: 0, detail: "0 家银行 · 0 个大类", target: "/operations/models", tone: "normal" },
{ key: "grades", label: "B / C 等级", value: 0, detail: "C 0 · B 0", target: "/operations/monitoring?grade=B,C", tone: "normal" },
{ key: "pending_reviews", label: "待处理结果", value: 0, detail: "到期未处理即默认暂不处理", target: "/operations/monitoring", tone: "normal" },
{ key: "intervention", label: "需主动干预", value: 0, detail: "KS < 30% 或 PSI > 50%", target: "/operations/monitoring", tone: "normal" },
{ key: "model_reports", label: "待我阅读报告", value: 0, detail: "超过 5 个工作日进入催办", target: "/operations/reports", tone: "normal" },
{ key: "stalled_workflows", label: "流程停滞", value: 0, detail: "超过节点期限未更新", target: "/operations/workflows", tone: "normal" },
];
const kpiToneClasses = {
normal: "border-border bg-card",
primary: "border-primary/25 bg-card",
warning: "border-warning/35 bg-[#fffaf5]",
danger: "border-danger/35 bg-[#fff7f6]",
} as const;
const itemToneClasses = {
normal: "bg-border",
primary: "bg-primary",
warning: "bg-warning",
danger: "bg-danger",
} as const;
function WorkbenchItem({ item, onNavigate }: { item: OperationsWorkbenchItem; onNavigate: (target: string) => void }) {
return (
<div className="flex items-start gap-3 border-b border-dashed border-border-2 px-4 py-3 last:border-b-0">
<span className={`mt-1.5 h-8 w-1 shrink-0 rounded-full ${itemToneClasses[item.tone]}`} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground">{item.title}</div>
<div className="mt-0.5 text-xs leading-5 text-muted-foreground">{item.detail}</div>
</div>
<Button
className="shrink-0"
variant={item.tone === "danger" ? "default" : "outline"}
size="sm"
onClick={() => onNavigate(item.target)}
>
{item.action_label}
</Button>
</div>
);
}
function EmptyList({ text }: { text: string }) {
return <div className="flex min-h-[72px] items-center justify-center px-4 py-6 text-center text-xs text-muted-foreground">{text}</div>;
}
export default function OperationsWorkbenchPage() {
const navigate = useNavigate();
const { 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 },
];
const { workbench } = useOperationsData();
const kpis = workbench.kpis.length === 6 ? workbench.kpis : FALLBACK_KPIS;
const todos = workbench.todos;
const watches = workbench.watches;
const activities = workbench.activities;
const serviceOnline = workbench.kpis.length === 6;
return (
<section className="h-full overflow-auto bg-bg 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>
)}
/>
<section className="h-full overflow-auto bg-bg px-6 py-5">
<div className="flex w-full flex-col gap-4 pb-8">
<section className="dashboard-hero-shell flex flex-col items-start justify-between gap-6 rounded-xl px-[34px] py-[30px] text-white shadow-lg sm:flex-row sm:items-center">
<div>
<span className="text-2xs tracking-[0.12em] opacity-80">MODEL OPERATIONS PLATFORM</span>
<h2 className="my-2 mb-[5px] text-2xl font-medium"></h2>
<p className="m-0 text-xs opacity-90"> KPI</p>
</div>
<span className="rounded-[20px] bg-white/16 px-3 py-2 text-sm">
{serviceOnline ? "服务已连接" : "服务连接中"}
</span>
</section>
<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>
{workbench.alerts.length > 0 && (
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-danger/25 bg-danger-soft px-4 py-3 text-sm text-danger">
<span className="size-2 shrink-0 rounded-full bg-danger" />
<span className="flex-1">{workbench.alerts.join(" ")}</span>
<Button variant="outline" size="sm" onClick={() => navigate("/operations/monitoring")}></Button>
</div>
)}
<div className="grid grid-cols-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 className="grid grid-cols-6 gap-3 max-[1180px]:grid-cols-3">
{kpis.map((kpi) => (
<button
className={`min-w-0 rounded-lg border p-4 text-left shadow-sm transition-colors hover:border-primary/45 ${kpiToneClasses[kpi.tone]}`}
key={kpi.key}
type="button"
onClick={() => navigate(kpi.target)}
title="点击查看明细"
>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span className="truncate">{kpi.label}</span>
</div>
<div className="mt-1 flex items-end gap-1">
<strong className="text-2xl font-semibold tabular-nums text-foreground">{kpi.value}</strong>
<span className="mb-0.5 text-lg leading-none text-muted-foreground"></span>
</div>
<div className="mt-1 truncate text-xs text-muted-foreground">{kpi.detail}</div>
</button>
))}
</div>
<div className="grid grid-cols-[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>
<div className="grid grid-cols-2 gap-4 max-[1180px]:grid-cols-1">
<div className="min-h-[144px] overflow-hidden rounded-lg border border-border bg-card shadow-sm">
<div className="flex items-center gap-3 border-b border-border-2 px-4 py-3">
<h2 className="text-sm font-semibold text-foreground"></h2>
<span className="text-xs text-muted-foreground"> / / </span>
<span className="ml-auto text-xs text-muted-foreground"> {todos.length} </span>
</div>
{todos.length ? todos.map((item) => <WorkbenchItem item={item} key={item.id} onNavigate={navigate} />) : <EmptyList text="暂无待办" />}
</div>
<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 className="min-h-[144px] overflow-hidden rounded-lg border border-border bg-card shadow-sm">
<div className="flex items-center gap-3 border-b border-border-2 px-4 py-3">
<h2 className="text-sm font-semibold text-foreground"></h2>
<span className="text-xs text-muted-foreground"></span>
<span className="ml-auto text-xs text-muted-foreground"> {watches.length} </span>
</div>
{watches.length ? watches.map((item) => <WorkbenchItem item={item} key={item.id} onNavigate={navigate} />) : <EmptyList text="暂无关注事项" />}
</div>
</div>
<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 className="min-h-[156px] overflow-hidden rounded-lg border border-border bg-card shadow-sm">
<div className="border-b border-border-2 px-4 py-3">
<h2 className="text-sm font-semibold text-foreground"></h2>
</div>
{activities.length ? (
<ul className="divide-y divide-dashed divide-border-2 px-4">
{activities.map((item, index) => (
<li className="flex gap-4 py-3 text-sm" key={`${item.occurred_at}-${index}`}>
<time className="w-36 shrink-0 text-xs tabular-nums text-muted-foreground">{item.occurred_at.replace("T", " ").slice(0, 16)}</time>
<span className="min-w-0 text-foreground"><b className="font-medium text-primary">{item.actor}</b> {item.text}</span>
</li>
))}
</ul>
) : <EmptyList text="暂无最近动态" />}
</div>
</div>
</section>
);