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,7 +1,6 @@
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";
@@ -37,13 +36,12 @@ type BankCategoryCard = {
modelCount: number;
versions: number;
latestIteration: string;
averageCycle: number;
averageCycle: number | null;
ownKs: number;
ownPsi: number;
peerKs: number;
peerPsi: number;
grades: Record<ModelGrade, number>;
demo?: boolean;
};
function isCategoryId(value: string | null): value is ModelCategoryId {
@@ -114,14 +112,13 @@ export default function BankOverviewPage() {
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 }];
return realCards;
}, [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 });
@@ -129,7 +126,7 @@ export default function BankOverviewPage() {
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">
<div className="flex w-full 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>
@@ -137,12 +134,12 @@ export default function BankOverviewPage() {
<CategoryCardRail>
{cards.map((item) => {
const total = item.grades.A + item.grades.B + item.grades.C || 1;
const selected = !item.demo && item.id === category;
const selected = 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">
const activate = () => 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">-{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"></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 !== null ? `${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>