You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

479 lines
17 KiB

import { useState, useEffect, useRef } from 'react';
import { motion } from 'framer-motion';
import {
Upload,
FileSpreadsheet,
Database,
History,
CheckCircle,
AlertCircle,
X,
Download,
Calendar,
Settings
} from 'lucide-react';
import { adminApi, createSyncTaskWebSocket } from '@/services/adminApi';
interface ImportTask {
id: string;
type: 'stock' | 'sector' | 'trade' | 'kline';
name: string;
fileName: string;
status: 'pending' | 'processing' | 'completed' | 'error';
progress: number;
totalRecords: number;
importedRecords: number;
errorMessage?: string;
createdAt: string;
}
const importTemplates = [
{ type: 'stock', name: '股票基础数据', format: 'CSV/Excel', fields: ['code', 'name', 'industry', 'market_cap'] },
{ type: 'sector', name: '版块数据', format: 'CSV/Excel', fields: ['code', 'name', 'parent_code'] },
{ type: 'trade', name: '交易数据', format: 'CSV/Excel', fields: ['code', 'date', 'open', 'high', 'low', 'close', 'volume'] },
{ type: 'kline', name: 'K线数据', format: 'CSV/Excel', fields: ['code', 'date', 'open', 'high', 'low', 'close', 'volume'] },
];
export default function DataImport() {
const [tasks, setTasks] = useState<ImportTask[]>([]);
const [loading, setLoading] = useState(true);
const [dragActive, setDragActive] = useState(false);
const [selectedType, setSelectedType] = useState<string>('stock');
const [showTemplateModal, setShowTemplateModal] = useState(false);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const wsRef = useRef<WebSocket | null>(null);
useEffect(() => {
fetchTasks();
}, []);
useEffect(() => {
return () => {
if (wsRef.current) {
wsRef.current.close();
}
};
}, []);
const fetchTasks = async () => {
setLoading(true);
try {
const data = await adminApi.getImportTasks();
setTasks(data.map(t => ({
id: t.id,
type: 'stock' as const,
name: t.name,
fileName: t.fileName,
status: t.status as ImportTask['status'],
progress: t.progress,
totalRecords: t.totalRecords,
importedRecords: t.importedRecords,
createdAt: t.createdAt,
})));
} catch (error) {
console.error('Failed to fetch tasks:', error);
} finally {
setLoading(false);
}
};
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true);
} else if (e.type === 'dragleave') {
setDragActive(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleFile(e.dataTransfer.files[0]);
}
};
const handleFile = async (file: File) => {
setUploading(true);
try {
const result = await adminApi.uploadImportFile(file, selectedType);
// 添加新任务到列表
const newTask: ImportTask = {
id: result.taskId,
type: selectedType as ImportTask['type'],
name: importTemplates.find(t => t.type === selectedType)?.name || '数据导入',
fileName: file.name,
status: 'pending',
progress: 0,
totalRecords: 0,
importedRecords: 0,
createdAt: new Date().toLocaleString('zh-CN'),
};
setTasks(prev => [newTask, ...prev]);
// 连接 WebSocket 获取进度
connectWebSocket(result.taskId, newTask.id);
} catch (error: any) {
alert('上传失败: ' + error.message);
} finally {
setUploading(false);
}
};
const connectWebSocket = (serverTaskId: string, localTaskId: string) => {
if (wsRef.current) {
wsRef.current.close();
}
const ws = createSyncTaskWebSocket(serverTaskId);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
setTasks(prev => prev.map(t =>
t.id === localTaskId ? {
...t,
status: data.status,
progress: data.progress,
totalRecords: data.totalRecords || t.totalRecords,
importedRecords: data.processedRecords || t.importedRecords,
errorMessage: data.error,
} : t
));
if (data.status === 'completed' || data.status === 'failed') {
ws.close();
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
wsRef.current = ws;
};
const handleDeleteTask = (taskId: string) => {
setTasks(prev => prev.filter(t => t.id !== taskId));
};
const getStatusIcon = (status: ImportTask['status']) => {
switch (status) {
case 'completed':
return <CheckCircle className="w-5 h-5 text-green-400" />;
case 'error':
return <AlertCircle className="w-5 h-5 text-red-400" />;
case 'processing':
return <div className="w-5 h-5 border-2 border-[#ff6b35] border-t-transparent rounded-full animate-spin" />;
default:
return <div className="w-5 h-5 rounded-full border-2 border-[#666]" />;
}
};
const downloadTemplate = (type: string) => {
const template = importTemplates.find(t => t.type === type);
if (!template) return;
const headers = template.fields.join(',');
const sample = template.fields.map(() => '示例数据').join(',');
const content = `${headers}\n${sample}`;
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `${type}_template.csv`;
link.click();
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white"></h1>
<p className="text-[#b0b0b0] mt-1"></p>
</div>
<button
onClick={() => setShowTemplateModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-[#2a2a2a] text-white rounded-lg hover:bg-[#333] transition-colors"
>
<Download className="w-4 h-4" />
</button>
</div>
{/* Upload Area */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className={`bg-[#111111] border-2 border-dashed rounded-xl p-8 text-center transition-colors ${
dragActive ? 'border-[#ff6b35] bg-[#ff6b35]/5' : 'border-[#2a2a2a]'
}`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
<input
ref={fileInputRef}
type="file"
accept=".csv,.xlsx,.xls"
onChange={(e) => e.target.files?.[0] && handleFile(e.target.files[0])}
className="hidden"
/>
<div className="w-16 h-16 bg-[#ff6b35]/20 rounded-full flex items-center justify-center mx-auto mb-4">
{uploading ? (
<div className="w-8 h-8 border-2 border-[#ff6b35] border-t-transparent rounded-full animate-spin" />
) : (
<Upload className="w-8 h-8 text-[#ff6b35]" />
)}
</div>
<p className="text-white text-lg mb-2">
{uploading ? '上传中...' : '拖拽文件到此处上传'}
</p>
<p className="text-[#666] text-sm mb-4"> CSVExcel </p>
{/* Data Type Selector */}
<div className="flex flex-wrap justify-center gap-2 mb-4">
{importTemplates.map(template => (
<button
key={template.type}
onClick={() => setSelectedType(template.type)}
disabled={uploading}
className={`px-4 py-2 rounded-lg text-sm transition-colors disabled:opacity-50 ${
selectedType === template.type
? 'bg-[#ff6b35] text-white'
: 'bg-[#2a2a2a] text-[#b0b0b0] hover:bg-[#333]'
}`}
>
{template.name}
</button>
))}
</div>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="px-6 py-2 bg-[#ff6b35] text-white rounded-lg hover:bg-[#ff6b35]/90 transition-colors disabled:opacity-50"
>
</button>
</motion.div>
{/* Import Tasks */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-[#111111] border border-[#2a2a2a] rounded-xl overflow-hidden"
>
<div className="px-5 py-4 border-b border-[#2a2a2a] flex items-center justify-between">
<div className="flex items-center gap-3">
<History className="w-5 h-5 text-[#ff6b35]" />
<h2 className="text-white font-semibold"></h2>
</div>
<div className="flex gap-2">
<button
onClick={fetchTasks}
disabled={loading}
className="p-2 hover:bg-[#2a2a2a] rounded-lg transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 text-[#666] ${loading ? 'animate-spin' : ''}`} />
</button>
<button
onClick={() => setTasks([])}
className="text-sm text-[#666] hover:text-white transition-colors"
>
</button>
</div>
</div>
<div className="divide-y divide-[#2a2a2a]">
{loading ? (
<div className="p-8 text-center text-[#b0b0b0]">
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2" />
...
</div>
) : tasks.length === 0 ? (
<div className="p-8 text-center text-[#666]">
</div>
) : (
tasks.map((task) => (
<div key={task.id} className="p-5 hover:bg-[#1a1a1a] transition-colors">
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
{getStatusIcon(task.status)}
<div>
<h3 className="text-white font-medium">{task.name}</h3>
<p className="text-sm text-[#666] mt-1">{task.fileName}</p>
<div className="flex items-center gap-4 mt-2 text-xs text-[#666]">
<span>{task.createdAt}</span>
<span>: {task.type}</span>
{task.status !== 'pending' && task.totalRecords > 0 && (
<span>
: {task.importedRecords.toLocaleString()} / {task.totalRecords.toLocaleString()}
</span>
)}
</div>
{task.errorMessage && (
<p className="text-sm text-red-400 mt-2">{task.errorMessage}</p>
)}
</div>
</div>
<button
onClick={() => handleDeleteTask(task.id)}
className="p-2 hover:bg-[#2a2a2a] rounded-lg transition-colors"
>
<X className="w-4 h-4 text-[#666]" />
</button>
</div>
{task.status === 'processing' && (
<div className="mt-4">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-[#b0b0b0]"></span>
<span className="text-[#ff6b35]">{task.progress.toFixed(0)}%</span>
</div>
<div className="h-2 bg-[#2a2a2a] rounded-full overflow-hidden">
<motion.div
className="h-full bg-gradient-to-r from-[#ff6b35] to-[#ff9f43]"
initial={{ width: 0 }}
animate={{ width: `${task.progress}%` }}
transition={{ duration: 0.3 }}
/>
</div>
</div>
)}
{task.status === 'completed' && (
<div className="mt-4 flex items-center gap-2 text-green-400 text-sm">
<CheckCircle className="w-4 h-4" />
</div>
)}
</div>
))
)}
</div>
</motion.div>
{/* Import Guide */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="bg-[#111111] border border-[#2a2a2a] rounded-xl p-5"
>
<h2 className="text-white font-semibold mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-blue-500/20 rounded-lg flex items-center justify-center flex-shrink-0">
<FileSpreadsheet className="w-4 h-4 text-blue-400" />
</div>
<div>
<h3 className="text-white font-medium"></h3>
<p className="text-sm text-[#666] mt-1">
CSV (.csv) Excel (.xlsx, .xls) 100MB
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-green-500/20 rounded-lg flex items-center justify-center flex-shrink-0">
<Database className="w-4 h-4 text-green-400" />
</div>
<div>
<h3 className="text-white font-medium"></h3>
<p className="text-sm text-[#666] mt-1">
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-purple-500/20 rounded-lg flex items-center justify-center flex-shrink-0">
<Calendar className="w-4 h-4 text-purple-400" />
</div>
<div>
<h3 className="text-white font-medium"></h3>
<p className="text-sm text-[#666] mt-1">
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-orange-500/20 rounded-lg flex items-center justify-center flex-shrink-0">
<Settings className="w-4 h-4 text-orange-400" />
</div>
<div>
<h3 className="text-white font-medium"></h3>
<p className="text-sm text-[#666] mt-1">
</p>
</div>
</div>
</div>
</motion.div>
{/* Template Modal */}
{showTemplateModal && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"
onClick={() => setShowTemplateModal(false)}
>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl w-full max-w-lg"
onClick={e => e.stopPropagation()}
>
<div className="p-5 border-b border-[#2a2a2a] flex items-center justify-between">
<h2 className="text-white font-semibold"></h2>
<button
onClick={() => setShowTemplateModal(false)}
className="p-2 hover:bg-[#2a2a2a] rounded-lg transition-colors"
>
<X className="w-5 h-5 text-[#666]" />
</button>
</div>
<div className="p-5 space-y-3">
{importTemplates.map(template => (
<div
key={template.type}
className="flex items-center justify-between p-4 bg-[#0a0a0a] rounded-lg"
>
<div>
<h3 className="text-white font-medium">{template.name}</h3>
<p className="text-sm text-[#666] mt-1">
: {template.fields.join(', ')}
</p>
</div>
<button
onClick={() => downloadTemplate(template.type)}
className="px-4 py-2 bg-[#ff6b35] text-white rounded-lg hover:bg-[#ff6b35]/90 transition-colors"
>
</button>
</div>
))}
</div>
</motion.div>
</motion.div>
)}
</div>
);
}