All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- FeedbackAdminPage / EleImportPage 头部加 ← 返回按钮: 优先 history.back(来自 SPA 内跳转),否则 hash=#mileage 兜底回主页 - 反馈入库(created_at / reply_at)改为 DATE_ADD(UTC_TIMESTAMP, INTERVAL 8 HOUR) 不再依赖 MySQL/容器的本地时区设置,固定 CST Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
393 lines
19 KiB
TypeScript
393 lines
19 KiB
TypeScript
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<string, string> = {
|
||
internal: '内部',
|
||
external: '外部',
|
||
};
|
||
const KIND_STYLE: Record<string, string> = {
|
||
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<UploadResult> {
|
||
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<File | null>(null);
|
||
const [uploading, setUploading] = useState(false);
|
||
const [result, setResult] = useState<UploadResult | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [items, setItems] = useState<ListItem[]>([]);
|
||
const [total, setTotal] = useState(0);
|
||
const [overall, setOverall] = useState<OverallRow[]>([]);
|
||
const [batches, setBatches] = useState<BatchRow[]>([]);
|
||
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<HTMLInputElement>(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 (
|
||
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 p-4 md:p-8">
|
||
<div className="max-w-6xl mx-auto space-y-4">
|
||
<header className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3 min-w-0">
|
||
<button
|
||
onClick={() => {
|
||
if (window.history.length > 1) window.history.back();
|
||
else { window.location.hash = '#mileage'; }
|
||
}}
|
||
className="w-9 h-9 rounded-xl bg-white border border-slate-100 hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 text-slate-500 flex items-center justify-center transition-colors flex-shrink-0"
|
||
title="返回"
|
||
>
|
||
<ArrowLeft size={16} />
|
||
</button>
|
||
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center flex-shrink-0">
|
||
<Zap size={18} className="text-white" />
|
||
</div>
|
||
<div className="min-w-0">
|
||
<h1 className="text-lg font-black text-slate-900 leading-tight">充电记录导入</h1>
|
||
<p className="text-[11px] font-bold text-slate-400">每日上传 xlsx · 订单编号去重 · 系统车辆自动匹配</p>
|
||
</div>
|
||
</div>
|
||
<span className="text-[10px] font-bold text-slate-400 flex-shrink-0">{user?.userName || ''}</span>
|
||
</header>
|
||
|
||
{/* 上传区 */}
|
||
<section
|
||
onDragOver={(e) => { 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'
|
||
}`}
|
||
>
|
||
<input
|
||
ref={inputRef}
|
||
type="file"
|
||
accept=".xlsx,.xls"
|
||
className="hidden"
|
||
onChange={(e) => onPick(e.target.files?.[0] || null)}
|
||
/>
|
||
<div className="px-6 py-10 flex flex-col items-center text-center">
|
||
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3">
|
||
{uploading ? <RotateCcw size={22} className="text-blue-500 animate-spin" /> : <Upload size={22} className="text-blue-500" />}
|
||
</div>
|
||
<div className="text-sm font-bold text-slate-700 mb-1">
|
||
{uploading ? '正在解析...' : file ? file.name : '点击或拖拽 xlsx 文件到此处'}
|
||
</div>
|
||
<div className="text-[11px] text-slate-400 max-w-md leading-relaxed">
|
||
支持「充电成功记录明细」格式;订单编号已存在的会自动跳过
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* 上传结果提示 */}
|
||
<AnimatePresence>
|
||
{result && (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 8 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: -8 }}
|
||
className="bg-white rounded-2xl border border-emerald-100 shadow-sm p-4 flex items-start gap-3"
|
||
>
|
||
<CheckCircle2 size={18} className="text-emerald-500 flex-shrink-0 mt-0.5" />
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-[12px] font-bold text-slate-700 mb-1">
|
||
上传成功:<span className="text-slate-500 font-mono">{result.filename}</span>
|
||
</div>
|
||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2 text-[11px]">
|
||
<Stat label="解析" value={result.parsed} color="text-slate-700" />
|
||
<Stat label="新增" value={result.inserted} color="text-blue-600" />
|
||
<Stat label="重复跳过" value={result.fileDuplicates + result.dbDuplicates} color="text-slate-500" />
|
||
<Stat label="内部" value={result.breakdown.internal} color="text-blue-600" />
|
||
<Stat label="外部(含无车牌)" value={result.breakdown.external} color="text-amber-600" />
|
||
</div>
|
||
</div>
|
||
<button onClick={() => setResult(null)} className="text-slate-300 hover:text-slate-600 text-[10px] font-bold">关闭</button>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
<AnimatePresence>
|
||
{error && (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 8 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: -8 }}
|
||
className="bg-red-50 rounded-2xl border border-red-200 shadow-sm p-4 flex items-start gap-3"
|
||
>
|
||
<AlertCircle size={18} className="text-red-500 flex-shrink-0 mt-0.5" />
|
||
<div className="flex-1 text-[12px] font-bold text-red-700">{error}</div>
|
||
<button onClick={() => setError(null)} className="text-red-300 hover:text-red-600 text-[10px] font-bold">关闭</button>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
{/* 聚合卡 */}
|
||
<section className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||
<KpiCard icon={<Layers size={14} />} label="总记录" value={totalRecords.toLocaleString()} />
|
||
<KpiCard icon={<Truck size={14} />} label="内部记录" value={(overallMap.get('internal')?.records ?? 0).toLocaleString()} accent="blue" />
|
||
<KpiCard icon={<ExternalLink size={14} />} label="外部记录" value={(overallMap.get('external')?.records ?? 0).toLocaleString()} accent="amber" />
|
||
<KpiCard icon={<Zap size={14} />} label="累计电量" value={`${totalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} />
|
||
<KpiCard icon={<Zap size={14} />} label="内部电量" value={`${(overallMap.get('internal')?.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} accent="blue" />
|
||
<KpiCard icon={<Zap size={14} />} label="外部电量" value={`${(overallMap.get('external')?.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} accent="amber" />
|
||
</section>
|
||
|
||
{/* 批次 */}
|
||
{batches.length > 0 && (
|
||
<section className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||
<div className="px-3 py-2 bg-slate-50 flex items-center justify-between">
|
||
<span className="text-[11px] font-bold text-slate-500">最近上传批次</span>
|
||
{batchFilter && (
|
||
<button onClick={() => setBatchFilter('')} className="text-[10px] font-bold text-blue-500">取消筛选</button>
|
||
)}
|
||
</div>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-[11px]">
|
||
<thead className="text-slate-400 font-bold bg-slate-50/40">
|
||
<tr>
|
||
<th className="px-3 py-2 text-left">导入时间</th>
|
||
<th className="px-3 py-2 text-right">总数</th>
|
||
<th className="px-3 py-2 text-right">内部</th>
|
||
<th className="px-3 py-2 text-right">外部</th>
|
||
<th className="px-3 py-2 text-right">电量(度)</th>
|
||
<th className="px-3 py-2 text-right">费用(元)</th>
|
||
<th className="px-3 py-2 text-right">批次</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{batches.map(b => (
|
||
<tr
|
||
key={b.batch_id}
|
||
onClick={() => 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'}`}
|
||
>
|
||
<td className="px-3 py-2 text-slate-600 whitespace-nowrap">{(b.imported_at || '').replace('T', ' ').slice(0, 19)}</td>
|
||
<td className="px-3 py-2 text-right font-bold text-slate-700">{Number(b.records).toLocaleString()}</td>
|
||
<td className="px-3 py-2 text-right text-blue-600 font-bold">{Number(b.internal_count).toLocaleString()}</td>
|
||
<td className="px-3 py-2 text-right text-amber-600 font-bold">{Number(b.external_count).toLocaleString()}</td>
|
||
<td className="px-3 py-2 text-right font-bold text-slate-600 tabular-nums">{Number(b.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })}</td>
|
||
<td className="px-3 py-2 text-right font-bold text-slate-600 tabular-nums">¥{Number(b.total_fee ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</td>
|
||
<td className="px-3 py-2 text-right text-slate-300 font-mono text-[10px]">{b.batch_id.slice(0, 12)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* 列表 */}
|
||
<section className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||
<div className="px-3 py-2 bg-slate-50 flex items-center gap-2 flex-wrap">
|
||
<span className="text-[11px] font-bold text-slate-500">最新记录</span>
|
||
<span className="text-[10px] font-bold text-slate-400">共 {total.toLocaleString()} 条</span>
|
||
<div className="flex-1" />
|
||
<input
|
||
type="text"
|
||
value={searchInput}
|
||
onChange={(e) => 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"
|
||
/>
|
||
<div className="flex gap-1 bg-white p-0.5 rounded-lg border border-slate-200">
|
||
{([['', '全部'], ['internal', '内部'], ['external', '外部']] as const).map(([k, label]) => (
|
||
<button
|
||
key={k}
|
||
onClick={() => setFilter(k as typeof filter)}
|
||
className={`px-2 py-0.5 rounded text-[10px] font-bold transition-colors ${filter === k ? 'bg-blue-50 text-blue-600' : 'text-slate-500 hover:bg-slate-50'}`}
|
||
>{label}</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-[11px]">
|
||
<thead className="bg-slate-50/40 text-slate-400 font-bold">
|
||
<tr>
|
||
<th className="px-3 py-2 text-left whitespace-nowrap">充电时间</th>
|
||
<th className="px-3 py-2 text-left whitespace-nowrap">车牌</th>
|
||
<th className="px-3 py-2 text-center whitespace-nowrap">分类</th>
|
||
<th className="px-3 py-2 text-left">电站 / 终端</th>
|
||
<th className="px-3 py-2 text-right whitespace-nowrap">电量(度)</th>
|
||
<th className="px-3 py-2 text-right whitespace-nowrap">费用(元)</th>
|
||
<th className="px-3 py-2 text-right whitespace-nowrap">时长(分)</th>
|
||
<th className="px-3 py-2 text-left whitespace-nowrap">订单编号</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{items.map(it => (
|
||
<tr key={it.id} className="border-t border-slate-100 hover:bg-slate-50/60">
|
||
<td className="px-3 py-2 text-slate-600 whitespace-nowrap font-mono">{(it.start_time || '').replace('T', ' ').slice(0, 16)}</td>
|
||
<td className="px-3 py-2 font-bold text-slate-700 font-mono whitespace-nowrap">{it.plate || it.judged_plate || <span className="text-slate-300">—</span>}</td>
|
||
<td className="px-3 py-2 text-center">
|
||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold border ${KIND_STYLE[it.vehicle_kind]}`}>
|
||
{KIND_LABEL[it.vehicle_kind]}
|
||
</span>
|
||
</td>
|
||
<td className="px-3 py-2 text-slate-600 truncate max-w-xs">{it.station_name || '—'}{it.terminal_name ? ` · ${it.terminal_name}` : ''}</td>
|
||
<td className="px-3 py-2 text-right text-slate-700 font-bold tabular-nums">{Number(it.kwh ?? 0).toFixed(2)}</td>
|
||
<td className="px-3 py-2 text-right text-slate-700 font-bold tabular-nums">{Number(it.fee ?? 0).toFixed(2)}</td>
|
||
<td className="px-3 py-2 text-right text-slate-500 tabular-nums">{it.duration_min ?? '—'}</td>
|
||
<td className="px-3 py-2 text-slate-400 font-mono text-[10px]">{it.order_no}</td>
|
||
</tr>
|
||
))}
|
||
{items.length === 0 && (
|
||
<tr>
|
||
<td colSpan={8} className="px-3 py-8 text-center text-slate-300 text-[11px] font-bold">
|
||
<FileSpreadsheet size={18} className="mx-auto mb-2 text-slate-200" />
|
||
尚无记录,先上传一份 xlsx 试试
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
<RotatingFooterHint className="pb-4" />
|
||
</div>
|
||
<FeedbackFab module="ele" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
|
||
return (
|
||
<div className="bg-slate-50 rounded-lg px-2 py-1.5">
|
||
<div className="text-[9px] text-slate-400 uppercase font-bold">{label}</div>
|
||
<div className={`text-sm font-black tabular-nums ${color}`}>{value}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function KpiCard({ icon, label, value, accent = 'slate' }: { icon: React.ReactNode; label: string; value: string; accent?: 'slate' | 'blue' | 'amber' }) {
|
||
const accentMap: Record<string, string> = {
|
||
slate: 'text-slate-700',
|
||
blue: 'text-blue-600',
|
||
amber: 'text-amber-600',
|
||
};
|
||
return (
|
||
<div className="bg-white rounded-xl border border-slate-100 shadow-sm p-3">
|
||
<div className="flex items-center gap-1 text-[10px] font-bold text-slate-400 uppercase">
|
||
<span className={accentMap[accent]}>{icon}</span>
|
||
<span>{label}</span>
|
||
</div>
|
||
<div className={`text-base font-black tabular-nums leading-tight mt-0.5 ${accentMap[accent]}`}>{value}</div>
|
||
</div>
|
||
);
|
||
}
|