feat: add A-card operations frontend and backend foundation
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user