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,5 +1,5 @@
import { useMemo, useRef, useState } from "react";
import { ArrowRight, Download, FileSpreadsheet, FolderOpen, Upload } from "lucide-react";
import { ArrowRight, Download, FileSpreadsheet, Upload } from "lucide-react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
@@ -10,7 +10,7 @@ import { usePersistentState } from "~/hooks/use-persistent-state";
import { exportRowsToExcel } from "~/lib/exportExcel";
import { FilterSelect, OperationsPageHeader, StatusBadge } from "./OperationsUi";
import { useOperationsData } from "./OperationsDataContext";
import { MODEL_LIFECYCLE, type ModelRecord } from "./modelData";
import type { ModelRecord } from "./modelData";
type Filters = {
bank: string;
@@ -23,11 +23,6 @@ function unique(values: string[]): string[] {
return [...new Set(values)].sort((left, right) => left.localeCompare(right, "zh-CN"));
}
function metricSeries(model: ModelRecord, base: readonly number[], variance: number): number[] {
const seed = [...model.modelId].reduce((sum, character) => sum + character.charCodeAt(0), 0);
return base.map((value, index) => Number((value * (1 + ((seed >> index) % 7 - 3) / variance)).toFixed(2)));
}
function BarList({
values,
labels,
@@ -43,13 +38,13 @@ function BarList({
const colorClass = tone === "warning" ? "bg-warning" : tone === "success" ? "bg-success" : "bg-brand";
return (
<div className="space-y-3">
{values.map((value, index) => (
{values.length ? values.map((value, index) => (
<div className="grid grid-cols-[5rem_minmax(0,1fr)_3.5rem] items-center gap-3" key={labels[index]}>
<span className="text-xs text-muted-foreground">{labels[index]}</span>
<span className="h-2.5 overflow-hidden rounded-full bg-muted"><i className={`block h-full rounded-full ${colorClass}`} style={{ width: `${value / max * 100}%` }} /></span>
<strong className="text-right text-xs tabular-nums text-foreground">{value.toFixed(2)}{suffix}</strong>
</div>
))}
)) : <div className="rounded-xl border border-dashed border-border p-6 text-center text-xs text-muted-foreground"></div>}
</div>
);
}
@@ -69,11 +64,10 @@ export default function DeployedModelsPage() {
&& (!filters.modelType || (model.commonModel ? "通用模型" : "个性化模型") === filters.modelType)
));
const model = filtered.find((item) => item.modelId === selectedModelId) ?? filtered[0] ?? null;
const lifecycle = model ? MODEL_LIFECYCLE[model.modelId] : null;
const scoreLabels = ["低分段", "中低分", "中分段", "中高分", "高分段", "最高分"];
const rankingRates = model ? metricSeries(model, [9.8, 7.2, 5.1, 3.4, 2.0, 1.1], 28) : [];
const liftValues = model ? metricSeries(model, [3.2, 2.4, 1.7, 1.1, 0.7, 0.4], 20) : [];
const psiValues = model ? metricSeries(model, [1.2, 0.9, 0.6, 0.5, 0.4, 0.3], 20) : [];
const rankingRates: number[] = [];
const liftValues: number[] = [];
const psiValues: number[] = [];
const scoreFile = model ? scoreFiles[model.modelId] : undefined;
const update = <K extends keyof Filters>(key: K, value: Filters[K]) => {
@@ -96,18 +90,13 @@ export default function DeployedModelsPage() {
};
const downloadScoreFile = () => {
if (!model) return;
void exportRowsToExcel({
fileName: scoreFile?.name.replace(/\.xlsx?$/i, "") ?? `${model.bank}_${model.modelId}_${model.version}_评分逻辑`,
sheetName: "评分逻辑",
headers: ["入模特征", "划分区间", "对应评分"],
rows: [["age", "[18,25)", 12], ["age", "[25,35)", 26], ["income", "[0,5000)", 8], ["income", "[5000,+)", 31], ["query_3m", "[0,2]", 22], ["query_3m", "[3,+)", 6]],
});
if (!model || !scoreFile) return;
toast.info("评分逻辑文件下载接口尚未接入");
};
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="查看已上线模型的生命周期、开发时点指标、评分逻辑和开发材料链接。"
@@ -136,7 +125,7 @@ export default function DeployedModelsPage() {
</CardContent>
</Card>
{model && lifecycle ? (
{model ? (
<>
<Card>
<CardHeader className="border-b border-border">
@@ -148,12 +137,12 @@ export default function DeployedModelsPage() {
{[
["银行 × 模型ID", `${model.bank} × ${model.modelId}`],
["版本", model.version],
["开发人员", lifecycle.developer],
["上线日期", lifecycle.onlineAt ?? "—"],
["开发人员", "—"],
["上线日期", "—"],
["最近迭代", model.iteratedAt],
["陪跑开始", lifecycle.escortStartAt ?? "—"],
["陪跑结束", lifecycle.escortEndAt ?? "—"],
["下线日期", lifecycle.offlineAt ?? "—"],
["陪跑开始", "—"],
["陪跑结束", "—"],
["下线日期", "—"],
["状态", model.status],
].map(([label, value]) => (
<div className="min-w-0 rounded-xl bg-muted/50 p-3" key={label}><span className="block whitespace-nowrap text-2xs text-muted-foreground">{label}</span><strong className="mt-1 block truncate text-sm font-semibold tabular-nums text-foreground" title={value}>{value}</strong></div>
@@ -167,11 +156,11 @@ export default function DeployedModelsPage() {
<CardContent><BarList values={rankingRates} labels={scoreLabels} tone="warning" /><p className="mt-4 rounded-xl bg-success-soft p-3 text-xs text-success-strong"></p></CardContent>
</Card>
<Card size="sm">
<CardHeader><CardTitle> · KS LIFT</CardTitle><CardDescription>KS {lifecycle.developmentKs.toFixed(1)}% · LIFT {lifecycle.maxLift.toFixed(2)}</CardDescription></CardHeader>
<CardHeader><CardTitle> · KS LIFT</CardTitle><CardDescription></CardDescription></CardHeader>
<CardContent><BarList values={liftValues} labels={scoreLabels} suffix="" tone="brand" /></CardContent>
</Card>
<Card size="sm">
<CardHeader><CardTitle> · PSI</CardTitle><CardDescription> OOT · PSI {lifecycle.developmentPsi.toFixed(2)}%</CardDescription></CardHeader>
<CardHeader><CardTitle> · PSI</CardTitle><CardDescription></CardDescription></CardHeader>
<CardContent><BarList values={psiValues} labels={scoreLabels} tone="success" /><p className="mt-4 text-xs text-muted-foreground">线 PSI </p></CardContent>
</Card>
</div>
@@ -182,7 +171,7 @@ export default function DeployedModelsPage() {
<CardContent className="space-y-4">
<div className="flex items-center gap-3 rounded-xl border border-border p-4">
<span className="grid size-10 place-items-center rounded-xl bg-success-soft text-success-strong"><FileSpreadsheet className="size-5" /></span>
<span className="min-w-0 flex-1"><b className="block truncate text-sm text-foreground">{scoreFile?.name ?? `${model.bank}_${model.modelId}_${model.version}_评分逻辑.xlsx`}</b><small className="text-xs text-muted-foreground">{scoreFile ? `本地待上传 · ${(scoreFile.size / 1024).toFixed(1)} KB · ${scoreFile.updatedAt}` : `模型团队 ${lifecycle.developer} · ${lifecycle.onlineAt ?? model.iteratedAt}`}</small></span>
<span className="min-w-0 flex-1"><b className="block truncate text-sm text-foreground">{scoreFile?.name ?? "暂无评分逻辑文件"}</b><small className="text-xs text-muted-foreground">{scoreFile ? `本地待上传 · ${(scoreFile.size / 1024).toFixed(1)} KB · ${scoreFile.updatedAt}` : "未关联评分逻辑文件"}</small></span>
<Button variant="outline" size="sm" onClick={downloadScoreFile}><Download /></Button>
</div>
<input ref={scoreInputRef} className="hidden" type="file" accept=".xlsx,.xls" onChange={(event) => { uploadScoreFile(event.target.files?.[0]); event.target.value = ""; }} />
@@ -196,9 +185,7 @@ export default function DeployedModelsPage() {
<CardContent className="px-0">
<Table>
<TableHeader><TableRow><TableHead></TableHead><TableHead></TableHead><TableHead className="text-right"></TableHead></TableRow></TableHeader>
<TableBody>{[
["模型设计方案", "方案设计"], ["开发结果材料", "开发迭代"], ["新老模型对比", "模型验证"], ["评审会议纪要", "评审决议"], ["一致性报告", "测试陪跑"],
].map(([name, stage]) => <TableRow key={name}><TableCell className="font-medium text-foreground"><span className="flex items-center gap-2"><FolderOpen className="size-4 text-primary" />{name}</span></TableCell><TableCell>{stage}</TableCell><TableCell className="text-right"><Button variant="link" size="sm" onClick={() => navigate(`/operations/knowledge?modelId=${encodeURIComponent(model.modelId)}&stage=${encodeURIComponent(stage)}`)}></Button></TableCell></TableRow>)}</TableBody>
<TableBody><TableRow><TableCell colSpan={3} className="h-28 text-center text-muted-foreground"></TableCell></TableRow></TableBody>
</Table>
</CardContent>
</Card>