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

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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>
);
}