Files
model-platform/frontend/app/features/operations/DeployedModelsPage.tsx
T

214 lines
13 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 { useMemo, useRef, useState } from "react";
import { ArrowRight, Download, FileSpreadsheet, FolderOpen, 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 { MODEL_LIFECYCLE, 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 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,
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.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>
);
}
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 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 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) 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]],
});
};
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={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 && lifecycle ? (
<>
<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],
["开发人员", 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>
))}
</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>KS {lifecycle.developmentKs.toFixed(1)}% · 最高 LIFT {lifecycle.maxLift.toFixed(2)}</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>
<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 ?? `${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>
<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>{[
["模型设计方案", "方案设计"], ["开发结果材料", "开发迭代"], ["新老模型对比", "模型验证"], ["评审会议纪要", "评审决议"], ["一致性报告", "测试陪跑"],
].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>
</Table>
</CardContent>
</Card>
</div>
</>
) : (
<Card><CardContent className="py-16 text-center text-sm text-muted-foreground">当前筛选条件下没有匹配的已上线模型</CardContent></Card>
)}
</div>
</section>
);
}