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

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

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

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

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

201 lines
11 KiB
TypeScript
Raw Permalink 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 { useMemo, useRef, useState } from "react";
import { ArrowRight, Download, FileSpreadsheet, Upload } from "lucide-react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
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 { usePersistentState } from "~/hooks/use-persistent-state";
import { exportRowsToExcel } from "~/lib/exportExcel";
import { FilterSelect, OperationsPageHeader, StatusBadge } from "./OperationsUi";
import { useOperationsData } from "./OperationsDataContext";
import type { ModelRecord } from "./modelData";
type Filters = {
bank: string;
name: string;
modelId: string;
modelType: string;
};
function unique(values: string[]): string[] {
return [...new Set(values)].sort((left, right) => left.localeCompare(right, "zh-CN"));
}
function BarList({
values,
labels,
suffix = "%",
tone = "brand",
}: {
values: number[];
labels: string[];
suffix?: string;
tone?: "brand" | "warning" | "success";
}) {
const max = Math.max(...values, 1);
const colorClass = tone === "warning" ? "bg-warning" : tone === "success" ? "bg-success" : "bg-brand";
return (
<div className="space-y-3">
{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>
);
}
export default function DeployedModelsPage() {
const navigate = useNavigate();
const { models } = useOperationsData();
const pool = useMemo(() => models.filter((model) => model.status !== "下线"), [models]);
const [filters, setFilters] = useState<Filters>({ bank: "", name: "", modelId: "", modelType: "" });
const [selectedModelId, setSelectedModelId] = useState(pool[0]?.modelId ?? "");
const [scoreFiles, setScoreFiles] = usePersistentState<Record<string, { name: string; size: number; updatedAt: string }>>("a-card-score-files", {});
const scoreInputRef = useRef<HTMLInputElement | null>(null);
const filtered = pool.filter((model) => (
(!filters.bank || model.bank === filters.bank)
&& (!filters.name || model.name === filters.name)
&& (!filters.modelId || model.modelId === filters.modelId)
&& (!filters.modelType || (model.commonModel ? "通用模型" : "个性化模型") === filters.modelType)
));
const model = filtered.find((item) => item.modelId === selectedModelId) ?? filtered[0] ?? null;
const scoreLabels = ["低分段", "中低分", "中分段", "中高分", "高分段", "最高分"];
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]) => {
setFilters((current) => ({ ...current, [key]: value }));
setSelectedModelId("");
};
const uploadScoreFile = (file: File | undefined) => {
if (!model || !file) return;
if (!/\.(xlsx|xls)$/i.test(file.name)) {
toast.error("请选择 .xlsx 或 .xls 文件");
return;
}
if (file.size > 20 * 1024 * 1024) {
toast.error("文件不能超过 20 MB");
return;
}
setScoreFiles((current) => ({ ...current, [model.modelId]: { name: file.name, size: file.size, updatedAt: new Date().toLocaleString("zh-CN", { hour12: false }) } }));
toast.success("评分逻辑文件已加入本地上传队列");
};
const downloadScoreFile = () => {
if (!model || !scoreFile) return;
toast.info("评分逻辑文件下载接口尚未接入");
};
return (
<section className="h-full overflow-auto bg-bg p-6">
<div className="flex w-full flex-col gap-6 pb-8">
<OperationsPageHeader
title="已上线模型详情"
description="查看已上线模型的生命周期、开发时点指标、评分逻辑和开发材料链接。"
actions={model && <Button variant="outline" onClick={() => navigate(`/operations/monitoring/${model.modelId}`)}>查看监控详情 <ArrowRight /></Button>}
/>
<Card>
<CardHeader className="border-b border-border">
<CardTitle>模型筛选</CardTitle>
<CardDescription>筛出 {filtered.length} 个模型</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="grid grid-cols-4 gap-3">
<FilterSelect label="银行" value={filters.bank} options={unique(pool.map((item) => item.bank))} onChange={(value) => update("bank", value)} />
<FilterSelect label="模型名称" value={filters.name} options={unique(pool.map((item) => item.name))} onChange={(value) => update("name", value)} />
<FilterSelect label="模型 ID" value={filters.modelId} options={pool.map((item) => item.modelId)} onChange={(value) => update("modelId", value)} />
<FilterSelect label="模型类型" value={filters.modelType} options={["通用模型", "个性化模型"]} onChange={(value) => update("modelType", value)} />
</div>
<FilterSelect
label="查看模型"
value={model?.modelId ?? ""}
allLabel={filtered.length ? "请选择模型" : "无匹配模型"}
options={filtered.map((item) => ({ label: `${item.bank} · ${item.name} ${item.version}${item.modelId}`, value: item.modelId }))}
onChange={setSelectedModelId}
/>
</CardContent>
</Card>
{model ? (
<>
<Card>
<CardHeader className="border-b border-border">
<CardTitle>{model.bank} · {model.name} {model.version}</CardTitle>
<CardDescription className="font-mono">{model.modelId}</CardDescription>
<CardAction className="flex items-center gap-2"><span className={model.commonModel ? "rounded-full bg-brand-soft px-2.5 py-1 text-xs font-medium text-primary" : "rounded-full bg-muted px-2.5 py-1 text-xs font-medium text-muted-foreground"}>{model.commonModel ? `通用 · ${model.commonModel}` : "个性化模型"}</span><StatusBadge status={model.status} /></CardAction>
</CardHeader>
<CardContent className="grid grid-cols-9 gap-3">
{[
["银行 × 模型ID", `${model.bank} × ${model.modelId}`],
["版本", model.version],
["开发人员", "—"],
["上线日期", "—"],
["最近迭代", model.iteratedAt],
["陪跑开始", "—"],
["陪跑结束", "—"],
["下线日期", "—"],
["状态", 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>
))}
</CardContent>
</Card>
<div className="grid grid-cols-3 gap-6">
<Card size="sm">
<CardHeader><CardTitle>开发时点 · 排序性</CardTitle><CardDescription>各评分区间坏客户占比</CardDescription></CardHeader>
<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>暂无开发时点指标</CardDescription></CardHeader>
<CardContent><BarList values={liftValues} labels={scoreLabels} suffix="" tone="brand" /></CardContent>
</Card>
<Card size="sm">
<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>
<div className="grid grid-cols-2 gap-6">
<Card>
<CardHeader><CardTitle>评分逻辑</CardTitle><CardDescription> Excel 文件维护,不在页面逐行展开</CardDescription></CardHeader>
<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 ?? "暂无评分逻辑文件"}</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 = ""; }} />
<Button className="w-full" variant="outline" onClick={() => scoreInputRef.current?.click()}><Upload />上传或替换评分逻辑 Excel</Button>
<p className="text-xs leading-5 text-muted-foreground">增量模型由开发评审材料自动带入;存量模型由模型团队手工上传。</p>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>模型开发材料</CardTitle><CardDescription>材料存放于开发评审域,本页仅提供链接</CardDescription></CardHeader>
<CardContent className="px-0">
<Table>
<TableHeader><TableRow><TableHead>材料</TableHead><TableHead>环节</TableHead><TableHead className="text-right">操作</TableHead></TableRow></TableHeader>
<TableBody><TableRow><TableCell colSpan={3} className="h-28 text-center text-muted-foreground">暂无模型开发材料</TableCell></TableRow></TableBody>
</Table>
</CardContent>
</Card>
</div>
</>
) : (
<Card><CardContent className="py-16 text-center text-sm text-muted-foreground">当前筛选条件下没有匹配的已上线模型</CardContent></Card>
)}
</div>
</section>
);
}