feat: 接入运维真实数据与三角色权限链路
- 新增 model_deploy 与 model_operations 双库查询,支持模型列表、模型详情、单月监控结果和运维工作台真实接口。 - 关闭运维模块生产 Mock 数据,补充缺表/空数据降级、工作台空状态和首条纵向链路测试。 - 按业务团队、模型团队、管理员权限矩阵接入菜单、页面、操作按钮和后端接口权限校验,支持 business_team 角色。 - 更新工作台布局、深色欢迎卡片、全宽页面适配、顶部回退,以及权限分组展示。 - 新增架构实现基线、周目标完成情况和角色权限矩阵初始化 SQL 文档。
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user