Reorganize navigation and module structure based on new requirements. Co-authored-by: null <4804959+fnvtk@users.noreply.github.com>
359 lines
13 KiB
TypeScript
359 lines
13 KiB
TypeScript
"use client"
|
|
|
|
import { useState } from "react"
|
|
import {
|
|
Boxes,
|
|
Plus,
|
|
Search,
|
|
Play,
|
|
Settings,
|
|
CheckCircle,
|
|
Clock,
|
|
AlertTriangle,
|
|
GitBranch,
|
|
BarChart3,
|
|
Cpu,
|
|
TrendingUp,
|
|
} from "lucide-react"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Progress } from "@/components/ui/progress"
|
|
import { UploadModelDialog } from "@/components/dialogs/upload-model-dialog"
|
|
|
|
interface Model {
|
|
id: string
|
|
name: string
|
|
type: "preset" | "custom"
|
|
category: "clv" | "rfm" | "churn" | "fraud" | "segment"
|
|
version: string
|
|
status: "online" | "training" | "testing" | "offline"
|
|
accuracy: number
|
|
precision: number
|
|
recall: number
|
|
f1Score: number
|
|
lastTrained: string
|
|
inputFeatures: string[]
|
|
outputFormat: string
|
|
updateFrequency: string
|
|
description: string
|
|
}
|
|
|
|
const MOCK_MODELS: Model[] = [
|
|
{
|
|
id: "1",
|
|
name: "CLV预测模型",
|
|
type: "preset",
|
|
category: "clv",
|
|
version: "v2.3.1",
|
|
status: "online",
|
|
accuracy: 87.5,
|
|
precision: 85.2,
|
|
recall: 89.1,
|
|
f1Score: 87.1,
|
|
lastTrained: "2025-12-10",
|
|
inputFeatures: ["历史消费金额", "消费频次", "注册时长", "活跃度"],
|
|
outputFormat: "CLV分数 (0-100)",
|
|
updateFrequency: "每周",
|
|
description: "基于用户历史行为预测客户终身价值",
|
|
},
|
|
{
|
|
id: "2",
|
|
name: "RFM评分模型",
|
|
type: "preset",
|
|
category: "rfm",
|
|
version: "v1.5.0",
|
|
status: "online",
|
|
accuracy: 92.3,
|
|
precision: 91.5,
|
|
recall: 93.2,
|
|
f1Score: 92.3,
|
|
lastTrained: "2025-12-11",
|
|
inputFeatures: ["最近消费时间", "消费频率", "消费金额"],
|
|
outputFormat: "R/F/M各维度评分 (0-100)",
|
|
updateFrequency: "每天",
|
|
description: "基于RFM模型的用户价值分层",
|
|
},
|
|
{
|
|
id: "3",
|
|
name: "流失预警模型",
|
|
type: "preset",
|
|
category: "churn",
|
|
version: "v3.1.2",
|
|
status: "online",
|
|
accuracy: 85.8,
|
|
precision: 87.3,
|
|
recall: 84.2,
|
|
f1Score: 85.7,
|
|
lastTrained: "2025-12-09",
|
|
inputFeatures: ["活跃度变化", "消费趋势", "投诉记录", "登录频次"],
|
|
outputFormat: "流失概率 (0-1)",
|
|
updateFrequency: "每天",
|
|
description: "预测用户未来30天流失概率",
|
|
},
|
|
{
|
|
id: "4",
|
|
name: "欺诈检测模型",
|
|
type: "preset",
|
|
category: "fraud",
|
|
version: "v2.0.0",
|
|
status: "online",
|
|
accuracy: 96.2,
|
|
precision: 94.8,
|
|
recall: 97.5,
|
|
f1Score: 96.1,
|
|
lastTrained: "2025-12-08",
|
|
inputFeatures: ["交易金额", "交易频率", "设备信息", "地理位置"],
|
|
outputFormat: "欺诈风险等级 (低/中/高)",
|
|
updateFrequency: "实时",
|
|
description: "实时检测可疑交易行为",
|
|
},
|
|
{
|
|
id: "5",
|
|
name: "用户分群模型",
|
|
type: "custom",
|
|
category: "segment",
|
|
version: "v1.0.0",
|
|
status: "training",
|
|
accuracy: 78.5,
|
|
precision: 76.2,
|
|
recall: 80.1,
|
|
f1Score: 78.1,
|
|
lastTrained: "2025-12-12",
|
|
inputFeatures: ["消费行为", "浏览偏好", "互动记录", "人口属性"],
|
|
outputFormat: "用户群体标签",
|
|
updateFrequency: "每周",
|
|
description: "基于多维特征的用户自动分群",
|
|
},
|
|
]
|
|
|
|
const CATEGORY_CONFIG = {
|
|
clv: { label: "CLV模型", color: "bg-blue-100 text-blue-700" },
|
|
rfm: { label: "RFM模型", color: "bg-green-100 text-green-700" },
|
|
churn: { label: "流失预警", color: "bg-orange-100 text-orange-700" },
|
|
fraud: { label: "欺诈检测", color: "bg-red-100 text-red-700" },
|
|
segment: { label: "用户分群", color: "bg-purple-100 text-purple-700" },
|
|
}
|
|
|
|
const STATUS_CONFIG = {
|
|
online: { label: "已上线", color: "bg-green-100 text-green-700", icon: CheckCircle },
|
|
training: { label: "训练中", color: "bg-blue-100 text-blue-700", icon: Cpu },
|
|
testing: { label: "测试中", color: "bg-yellow-100 text-yellow-700", icon: Clock },
|
|
offline: { label: "已下线", color: "bg-gray-100 text-gray-700", icon: AlertTriangle },
|
|
}
|
|
|
|
export default function ModelManagementPage() {
|
|
const [models, setModels] = useState<Model[]>(MOCK_MODELS)
|
|
const [searchQuery, setSearchQuery] = useState("")
|
|
const [selectedModel, setSelectedModel] = useState<Model | null>(null)
|
|
const [showUploadDialog, setShowUploadDialog] = useState(false)
|
|
|
|
const stats = {
|
|
total: models.length,
|
|
online: models.filter((m) => m.status === "online").length,
|
|
avgAccuracy: (models.reduce((sum, m) => sum + m.accuracy, 0) / models.length).toFixed(1),
|
|
presetModels: models.filter((m) => m.type === "preset").length,
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6 p-6">
|
|
{/* Header */}
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">模型管理</h1>
|
|
<p className="text-gray-500 mt-1">管理价值评估算法模型</p>
|
|
</div>
|
|
<Button
|
|
className="bg-gradient-to-r from-blue-500 to-purple-500 text-white"
|
|
onClick={() => setShowUploadDialog(true)}
|
|
>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
上传自定义模型
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<Card className="border-none shadow-sm bg-white/60 backdrop-blur-sm">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-2 text-gray-500 text-sm mb-1">
|
|
<Boxes className="w-4 h-4" />
|
|
总模型数
|
|
</div>
|
|
<div className="text-2xl font-bold text-gray-900">{stats.total}</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="border-none shadow-sm bg-white/60 backdrop-blur-sm">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-2 text-gray-500 text-sm mb-1">
|
|
<CheckCircle className="w-4 h-4 text-green-500" />
|
|
已上线
|
|
</div>
|
|
<div className="text-2xl font-bold text-green-600">{stats.online}</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="border-none shadow-sm bg-white/60 backdrop-blur-sm">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-2 text-gray-500 text-sm mb-1">
|
|
<TrendingUp className="w-4 h-4" />
|
|
平均准确率
|
|
</div>
|
|
<div className="text-2xl font-bold text-gray-900">{stats.avgAccuracy}%</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="border-none shadow-sm bg-white/60 backdrop-blur-sm">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-2 text-gray-500 text-sm mb-1">
|
|
<GitBranch className="w-4 h-4" />
|
|
预置模型
|
|
</div>
|
|
<div className="text-2xl font-bold text-gray-900">{stats.presetModels}</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Search */}
|
|
<div className="relative max-w-md">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
|
<Input
|
|
placeholder="搜索模型..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-10 bg-white/60"
|
|
/>
|
|
</div>
|
|
|
|
{/* Model List */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{models
|
|
.filter((m) => m.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
.map((model) => {
|
|
const StatusIcon = STATUS_CONFIG[model.status].icon
|
|
|
|
return (
|
|
<Card
|
|
key={model.id}
|
|
className={`border-none shadow-sm bg-white/60 backdrop-blur-sm hover:shadow-md transition-shadow cursor-pointer ${selectedModel?.id === model.id ? "ring-2 ring-blue-500" : ""}`}
|
|
onClick={() => setSelectedModel(model)}
|
|
>
|
|
<CardContent className="p-5">
|
|
<div className="flex items-start justify-between mb-4">
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<h3 className="font-semibold text-gray-900">{model.name}</h3>
|
|
<Badge className={CATEGORY_CONFIG[model.category].color}>
|
|
{CATEGORY_CONFIG[model.category].label}
|
|
</Badge>
|
|
</div>
|
|
<p className="text-sm text-gray-500">{model.description}</p>
|
|
</div>
|
|
<Badge className={STATUS_CONFIG[model.status].color}>
|
|
<StatusIcon className={`w-3 h-3 mr-1 ${model.status === "training" ? "animate-spin" : ""}`} />
|
|
{STATUS_CONFIG[model.status].label}
|
|
</Badge>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-4 gap-4 mb-4">
|
|
<div className="text-center p-2 rounded-lg bg-gray-50">
|
|
<div className="text-lg font-bold text-gray-900">{model.accuracy}%</div>
|
|
<div className="text-xs text-gray-500">准确率</div>
|
|
</div>
|
|
<div className="text-center p-2 rounded-lg bg-gray-50">
|
|
<div className="text-lg font-bold text-gray-900">{model.precision}%</div>
|
|
<div className="text-xs text-gray-500">精确率</div>
|
|
</div>
|
|
<div className="text-center p-2 rounded-lg bg-gray-50">
|
|
<div className="text-lg font-bold text-gray-900">{model.recall}%</div>
|
|
<div className="text-xs text-gray-500">召回率</div>
|
|
</div>
|
|
<div className="text-center p-2 rounded-lg bg-gray-50">
|
|
<div className="text-lg font-bold text-gray-900">{model.f1Score}%</div>
|
|
<div className="text-xs text-gray-500">F1分数</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between text-sm text-gray-500 pt-4 border-t border-gray-100">
|
|
<div className="flex items-center gap-4">
|
|
<span>版本: {model.version}</span>
|
|
<span>更新: {model.updateFrequency}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" size="sm">
|
|
<Play className="w-4 h-4 mr-1" />
|
|
测试
|
|
</Button>
|
|
<Button variant="ghost" size="sm">
|
|
<Settings className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Model Detail Panel */}
|
|
{selectedModel && (
|
|
<Card className="border-none shadow-md bg-white/80 backdrop-blur-sm">
|
|
<CardHeader>
|
|
<CardTitle className="text-lg flex items-center gap-2">
|
|
<BarChart3 className="w-5 h-5" />
|
|
模型详情: {selectedModel.name}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div>
|
|
<h4 className="font-medium text-gray-900 mb-3">输入特征</h4>
|
|
<div className="space-y-2">
|
|
{selectedModel.inputFeatures.map((feature, i) => (
|
|
<div key={i} className="flex items-center gap-2 p-2 rounded-lg bg-gray-50">
|
|
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
|
<span className="text-sm text-gray-700">{feature}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h4 className="font-medium text-gray-900 mb-3">输出格式</h4>
|
|
<div className="p-3 rounded-lg bg-gray-50">
|
|
<code className="text-sm text-gray-700">{selectedModel.outputFormat}</code>
|
|
</div>
|
|
|
|
<h4 className="font-medium text-gray-900 mb-3 mt-6">模型指标</h4>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<div className="flex justify-between text-sm mb-1">
|
|
<span>准确率</span>
|
|
<span>{selectedModel.accuracy}%</span>
|
|
</div>
|
|
<Progress value={selectedModel.accuracy} className="h-2" />
|
|
</div>
|
|
<div>
|
|
<div className="flex justify-between text-sm mb-1">
|
|
<span>精确率</span>
|
|
<span>{selectedModel.precision}%</span>
|
|
</div>
|
|
<Progress value={selectedModel.precision} className="h-2" />
|
|
</div>
|
|
<div>
|
|
<div className="flex justify-between text-sm mb-1">
|
|
<span>召回率</span>
|
|
<span>{selectedModel.recall}%</span>
|
|
</div>
|
|
<Progress value={selectedModel.recall} className="h-2" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<UploadModelDialog open={showUploadDialog} onOpenChange={setShowUploadDialog} />
|
|
</div>
|
|
)
|
|
}
|