Files
model-platform/frontend/app/features/operations/OperationsWorkbenchPage.tsx
T
郑龙捷 74dd97428f feat: 接入运维真实数据与三角色权限链路
- 新增 model_deploy 与 model_operations 双库查询,支持模型列表、模型详情、单月监控结果和运维工作台真实接口。

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

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

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

- 新增架构实现基线、周目标完成情况和角色权限矩阵初始化 SQL 文档。
2026-09-02 15:02:47 +08:00

145 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useNavigate } from "react-router";
import { Button } from "~/components/ui/button";
import type { OperationsWorkbenchItem, OperationsWorkbenchKpi } from "~/services/operationsApi";
import { useOperationsData } from "./OperationsDataContext";
const FALLBACK_KPIS: OperationsWorkbenchKpi[] = [
{ key: "models", label: "在管模型", value: 0, detail: "0 家银行 · 0 个大类", target: "/operations/models", tone: "normal" },
{ key: "grades", label: "B / C 等级", value: 0, detail: "C 0 · B 0", target: "/operations/monitoring?grade=B,C", tone: "normal" },
{ key: "pending_reviews", label: "待处理结果", value: 0, detail: "到期未处理即默认暂不处理", target: "/operations/monitoring", tone: "normal" },
{ key: "intervention", label: "需主动干预", value: 0, detail: "KS < 30% 或 PSI > 50%", target: "/operations/monitoring", tone: "normal" },
{ key: "model_reports", label: "待我阅读报告", value: 0, detail: "超过 5 个工作日进入催办", target: "/operations/reports", tone: "normal" },
{ key: "stalled_workflows", label: "流程停滞", value: 0, detail: "超过节点期限未更新", target: "/operations/workflows", tone: "normal" },
];
const kpiToneClasses = {
normal: "border-border bg-card",
primary: "border-primary/25 bg-card",
warning: "border-warning/35 bg-[#fffaf5]",
danger: "border-danger/35 bg-[#fff7f6]",
} as const;
const itemToneClasses = {
normal: "bg-border",
primary: "bg-primary",
warning: "bg-warning",
danger: "bg-danger",
} as const;
function WorkbenchItem({ item, onNavigate }: { item: OperationsWorkbenchItem; onNavigate: (target: string) => void }) {
return (
<div className="flex items-start gap-3 border-b border-dashed border-border-2 px-4 py-3 last:border-b-0">
<span className={`mt-1.5 h-8 w-1 shrink-0 rounded-full ${itemToneClasses[item.tone]}`} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground">{item.title}</div>
<div className="mt-0.5 text-xs leading-5 text-muted-foreground">{item.detail}</div>
</div>
<Button
className="shrink-0"
variant={item.tone === "danger" ? "default" : "outline"}
size="sm"
onClick={() => onNavigate(item.target)}
>
{item.action_label}
</Button>
</div>
);
}
function EmptyList({ text }: { text: string }) {
return <div className="flex min-h-[72px] items-center justify-center px-4 py-6 text-center text-xs text-muted-foreground">{text}</div>;
}
export default function OperationsWorkbenchPage() {
const navigate = useNavigate();
const { workbench } = useOperationsData();
const kpis = workbench.kpis.length === 6 ? workbench.kpis : FALLBACK_KPIS;
const todos = workbench.todos;
const watches = workbench.watches;
const activities = workbench.activities;
const serviceOnline = workbench.kpis.length === 6;
return (
<section className="h-full overflow-auto bg-bg px-6 py-5">
<div className="flex w-full flex-col gap-4 pb-8">
<section className="dashboard-hero-shell flex flex-col items-start justify-between gap-6 rounded-xl px-[34px] py-[30px] text-white shadow-lg sm:flex-row sm:items-center">
<div>
<span className="text-2xs tracking-[0.12em] opacity-80">MODEL OPERATIONS PLATFORM</span>
<h2 className="my-2 mb-[5px] text-2xl font-medium">运维工作台</h2>
<p className="m-0 text-xs opacity-90">一屏内可见 KPI、我的待办、我的关注与最近动态</p>
</div>
<span className="rounded-[20px] bg-white/16 px-3 py-2 text-sm">
{serviceOnline ? "服务已连接" : "服务连接中"}
</span>
</section>
{workbench.alerts.length > 0 && (
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-danger/25 bg-danger-soft px-4 py-3 text-sm text-danger">
<span className="size-2 shrink-0 rounded-full bg-danger" />
<span className="flex-1">{workbench.alerts.join(" ")}</span>
<Button variant="outline" size="sm" onClick={() => navigate("/operations/monitoring")}>查看监控概览</Button>
</div>
)}
<div className="grid grid-cols-6 gap-3 max-[1180px]:grid-cols-3">
{kpis.map((kpi) => (
<button
className={`min-w-0 rounded-lg border p-4 text-left shadow-sm transition-colors hover:border-primary/45 ${kpiToneClasses[kpi.tone]}`}
key={kpi.key}
type="button"
onClick={() => navigate(kpi.target)}
title="点击查看明细"
>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span className="truncate">{kpi.label}</span>
</div>
<div className="mt-1 flex items-end gap-1">
<strong className="text-2xl font-semibold tabular-nums text-foreground">{kpi.value}</strong>
<span className="mb-0.5 text-lg leading-none text-muted-foreground"></span>
</div>
<div className="mt-1 truncate text-xs text-muted-foreground">{kpi.detail}</div>
</button>
))}
</div>
<div className="grid grid-cols-2 gap-4 max-[1180px]:grid-cols-1">
<div className="min-h-[144px] overflow-hidden rounded-lg border border-border bg-card shadow-sm">
<div className="flex items-center gap-3 border-b border-border-2 px-4 py-3">
<h2 className="text-sm font-semibold text-foreground">我的待办</h2>
<span className="text-xs text-muted-foreground">按催办状态 / 截止日期 / 监控结果等级排序</span>
<span className="ml-auto text-xs text-muted-foreground"> {todos.length} </span>
</div>
{todos.length ? todos.map((item) => <WorkbenchItem item={item} key={item.id} onNavigate={navigate} />) : <EmptyList text="暂无待办" />}
</div>
<div className="min-h-[144px] overflow-hidden rounded-lg border border-border bg-card shadow-sm">
<div className="flex items-center gap-3 border-b border-border-2 px-4 py-3">
<h2 className="text-sm font-semibold text-foreground">我的关注</h2>
<span className="text-xs text-muted-foreground">只收纳“查看进度”类事项,不计入待办</span>
<span className="ml-auto text-xs text-muted-foreground"> {watches.length} </span>
</div>
{watches.length ? watches.map((item) => <WorkbenchItem item={item} key={item.id} onNavigate={navigate} />) : <EmptyList text="暂无关注事项" />}
</div>
</div>
<div className="min-h-[156px] overflow-hidden rounded-lg border border-border bg-card shadow-sm">
<div className="border-b border-border-2 px-4 py-3">
<h2 className="text-sm font-semibold text-foreground">最近动态</h2>
</div>
{activities.length ? (
<ul className="divide-y divide-dashed divide-border-2 px-4">
{activities.map((item, index) => (
<li className="flex gap-4 py-3 text-sm" key={`${item.occurred_at}-${index}`}>
<time className="w-36 shrink-0 text-xs tabular-nums text-muted-foreground">{item.occurred_at.replace("T", " ").slice(0, 16)}</time>
<span className="min-w-0 text-foreground"><b className="font-medium text-primary">{item.actor}</b> {item.text}</span>
</li>
))}
</ul>
) : <EmptyList text="暂无最近动态" />}
</div>
</div>
</section>
);
}