import { useCallback, useEffect, useRef, useState } from 'react'; import { motion, AnimatePresence } from 'motion/react'; import { Upload, FileSpreadsheet, RotateCcw, CheckCircle2, AlertCircle, Truck, ExternalLink, Layers, Zap, ArrowLeft, } from 'lucide-react'; import { fetchJson } from '../../auth/api-client'; import { useAuth } from '../../auth/useAuth'; import RotatingFooterHint from '../../components/RotatingFooterHint'; import FeedbackFab from '../../components/FeedbackFab'; function getJwt(): string | null { return sessionStorage.getItem('bi_jwt'); } interface UploadResult { ok: boolean; filename: string; batchId: string; parsed: number; fileDuplicates: number; inserted: number; dbDuplicates: number; breakdown: { internal: number; external: number }; } interface ListItem { id: number; order_no: string; station_name: string | null; terminal_name: string | null; region: string | null; city: string | null; start_time: string | null; end_time: string | null; duration_min: number | null; kwh: number | null; fee: number | null; e_fee: number | null; service_fee: number | null; plate: string | null; judged_plate: string | null; customer_name: string | null; vehicle_kind: 'internal' | 'external' | 'unknown'; batch_id: string; imported_at: string; } interface OverallRow { vehicle_kind: 'internal' | 'external'; records: number; total_kwh: number; total_fee: number; } interface BatchRow { batch_id: string; imported_at: string; records: number; internal_count: number; external_count: number; total_kwh: number; total_fee: number; } const KIND_LABEL: Record = { internal: '内部', external: '外部', }; const KIND_STYLE: Record = { internal: 'bg-blue-50 text-blue-600 border-blue-200', external: 'bg-amber-50 text-amber-600 border-amber-200', }; async function uploadFile(file: File): Promise { const fd = new FormData(); fd.append('file', file); const token = getJwt(); const res = await fetch('/api/ele/import', { method: 'POST', headers: token ? { Authorization: `Bearer ${token}` } : {}, body: fd, }); const json = await res.json(); if (!res.ok || !json.ok) throw new Error(json.message || `上传失败 (${res.status})`); return json as UploadResult; } export default function EleImportPage() { const { user } = useAuth(); const [file, setFile] = useState(null); const [uploading, setUploading] = useState(false); const [result, setResult] = useState(null); const [error, setError] = useState(null); const [items, setItems] = useState([]); const [total, setTotal] = useState(0); const [overall, setOverall] = useState([]); const [batches, setBatches] = useState([]); const [filter, setFilter] = useState<'' | 'internal' | 'external'>(''); const [batchFilter, setBatchFilter] = useState(''); const [search, setSearch] = useState(''); const [searchInput, setSearchInput] = useState(''); const [dragOver, setDragOver] = useState(false); const inputRef = useRef(null); const reload = useCallback(async () => { const params = new URLSearchParams({ page: '1', limit: '50' }); if (filter) params.set('kind', filter); if (batchFilter) params.set('batchId', batchFilter); if (search) params.set('search', search); const [list, agg, b] = await Promise.all([ fetchJson<{ items: ListItem[]; total: number }>(`/api/ele/list?${params.toString()}`), fetchJson<{ overall: OverallRow[] }>(`/api/ele/aggregate`), fetchJson<{ items: BatchRow[] }>(`/api/ele/batches`), ]); setItems(list.items); setTotal(list.total); setOverall(agg.overall); setBatches(b.items); }, [filter, batchFilter, search]); useEffect(() => { reload().catch(e => console.error(e)); }, [reload]); const handleUpload = async (f: File) => { setUploading(true); setError(null); setResult(null); try { const r = await uploadFile(f); setResult(r); await reload(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setUploading(false); } }; const onPick = (f: File | null) => { setFile(f); if (f) handleUpload(f); }; const overallMap = new Map(overall.map(o => [o.vehicle_kind, o])); const totalRecords = overall.reduce((s, o) => s + Number(o.records || 0), 0); const totalKwh = overall.reduce((s, o) => s + Number(o.total_kwh || 0), 0); const totalFee = overall.reduce((s, o) => s + Number(o.total_fee || 0), 0); return (

充电记录导入

每日上传 xlsx · 订单编号去重 · 系统车辆自动匹配

{user?.userName || ''}
{/* 上传区 */}
{ e.preventDefault(); setDragOver(true); }} onDragLeave={() => setDragOver(false)} onDrop={(e) => { e.preventDefault(); setDragOver(false); const f = e.dataTransfer.files?.[0]; if (f) onPick(f); }} onClick={() => inputRef.current?.click()} className={`bg-white rounded-2xl border-2 border-dashed shadow-sm cursor-pointer transition-all ${ dragOver ? 'border-blue-400 bg-blue-50/40' : uploading ? 'border-slate-200' : 'border-slate-200 hover:border-blue-300' }`} > onPick(e.target.files?.[0] || null)} />
{uploading ? : }
{uploading ? '正在解析...' : file ? file.name : '点击或拖拽 xlsx 文件到此处'}
支持「充电成功记录明细」格式;订单编号已存在的会自动跳过
{/* 上传结果提示 */} {result && (
上传成功:{result.filename}
)}
{error && (
{error}
)}
{/* 聚合卡 */}
} label="总记录" value={totalRecords.toLocaleString()} /> } label="内部记录" value={(overallMap.get('internal')?.records ?? 0).toLocaleString()} accent="blue" /> } label="外部记录" value={(overallMap.get('external')?.records ?? 0).toLocaleString()} accent="amber" /> } label="累计电量" value={`${totalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} /> } label="内部电量" value={`${(overallMap.get('internal')?.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} accent="blue" /> } label="外部电量" value={`${(overallMap.get('external')?.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} accent="amber" />
{/* 批次 */} {batches.length > 0 && (
最近上传批次 {batchFilter && ( )}
{batches.map(b => ( setBatchFilter(batchFilter === b.batch_id ? '' : b.batch_id)} className={`border-t border-slate-100 cursor-pointer transition-colors ${batchFilter === b.batch_id ? 'bg-blue-50/40' : 'hover:bg-slate-50/60'}`} > ))}
导入时间 总数 内部 外部 电量(度) 费用(元) 批次
{(b.imported_at || '').replace('T', ' ').slice(0, 19)} {Number(b.records).toLocaleString()} {Number(b.internal_count).toLocaleString()} {Number(b.external_count).toLocaleString()} {Number(b.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} ¥{Number(b.total_fee ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} {b.batch_id.slice(0, 12)}
)} {/* 列表 */}
最新记录 共 {total.toLocaleString()} 条
setSearchInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') setSearch(searchInput); }} placeholder="搜索订单/车牌/电站" className="bg-white border border-slate-200 rounded-lg px-2 py-1 text-[11px] outline-none focus:ring-1 focus:ring-blue-500/20 w-44" />
{([['', '全部'], ['internal', '内部'], ['external', '外部']] as const).map(([k, label]) => ( ))}
{items.map(it => ( ))} {items.length === 0 && ( )}
充电时间 车牌 分类 电站 / 终端 电量(度) 费用(元) 时长(分) 订单编号
{(it.start_time || '').replace('T', ' ').slice(0, 16)} {it.plate || it.judged_plate || } {KIND_LABEL[it.vehicle_kind]} {it.station_name || '—'}{it.terminal_name ? ` · ${it.terminal_name}` : ''} {Number(it.kwh ?? 0).toFixed(2)} {Number(it.fee ?? 0).toFixed(2)} {it.duration_min ?? '—'} {it.order_no}
尚无记录,先上传一份 xlsx 试试
); } function Stat({ label, value, color }: { label: string; value: number; color: string }) { return (
{label}
{value}
); } function KpiCard({ icon, label, value, accent = 'slate' }: { icon: React.ReactNode; label: string; value: string; accent?: 'slate' | 'blue' | 'amber' }) { const accentMap: Record = { slate: 'text-slate-700', blue: 'text-blue-600', amber: 'text-amber-600', }; return (
{icon} {label}
{value}
); }