All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
170 lines
8.2 KiB
TypeScript
170 lines
8.2 KiB
TypeScript
import { useState } from 'react';
|
||
import { ArrowDownUp, CheckCircle, Send, X, Ban } from 'lucide-react';
|
||
import { sendNotify, updateNotification } from './api';
|
||
import type { SchedulingSuggestion, CandidateVehicle } from './types';
|
||
import Blur from '../../components/Blur';
|
||
|
||
interface Props {
|
||
suggestion: SchedulingSuggestion;
|
||
candidate: CandidateVehicle;
|
||
onClose: () => void;
|
||
onSuccess: () => void;
|
||
}
|
||
|
||
function fmtKm(value: number): string {
|
||
if (value >= 10000) return (value / 10000).toFixed(1) + '万';
|
||
return Math.round(value).toLocaleString();
|
||
}
|
||
|
||
function fmtRate(rate: number): string {
|
||
return (rate * 100).toFixed(1) + '%';
|
||
}
|
||
|
||
export default function SwapPreview({ suggestion: s, candidate: c, onClose, onSuccess }: Props) {
|
||
const [sending, setSending] = useState(false);
|
||
const [sent, setSent] = useState(false);
|
||
const [cancelling, setCancelling] = useState(false);
|
||
const v = s.currentVehicle;
|
||
|
||
const alreadyIntervened =
|
||
!sent && (c.notificationStatus === 'sent' || c.notificationStatus === 'executed');
|
||
const isExecuted = c.notificationStatus === 'executed';
|
||
|
||
const handleSend = async () => {
|
||
if (sending || sent || alreadyIntervened) return;
|
||
setSending(true);
|
||
try {
|
||
const result = await sendNotify({ suggestionId: s.id, currentPlate: v.plateNumber, candidatePlate: c.plateNumber });
|
||
if (result.success) { setSent(true); onSuccess(); } else { alert(result.message || '发送失败'); }
|
||
} catch { alert('网络错误'); } finally { setSending(false); }
|
||
};
|
||
|
||
const handleCancel = async () => {
|
||
if (!c.notificationId || cancelling) return;
|
||
if (isExecuted) {
|
||
if (!confirm('此干预已标记为执行。确定要取消吗?')) return;
|
||
} else {
|
||
if (!confirm(`确定取消 ${v.plateNumber} → ${c.plateNumber} 的干预?`)) return;
|
||
}
|
||
setCancelling(true);
|
||
try {
|
||
const result = await updateNotification(c.notificationId, { status: 'cancelled' });
|
||
if (result.success) { onSuccess(); onClose(); }
|
||
else { alert(result.message || '取消失败'); }
|
||
} catch { alert('网络错误'); } finally { setCancelling(false); }
|
||
};
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-[80] bg-[#F0F4F8] flex flex-col">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between px-5 py-3 bg-white border-b border-slate-200 flex-shrink-0">
|
||
<span className="text-sm font-bold text-slate-800">车辆替换方案</span>
|
||
<button onClick={onClose} className="p-1 text-slate-400 hover:text-slate-600 cursor-pointer"><X size={20} /></button>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="flex-1 overflow-auto px-5 py-5">
|
||
<div className="max-w-sm mx-auto space-y-4">
|
||
|
||
{/* === Swap Cards === */}
|
||
<div className="relative">
|
||
{/* Current vehicle */}
|
||
<div className="bg-white rounded-2xl p-4 border border-slate-200 shadow-sm">
|
||
<div className="flex items-start justify-between">
|
||
<div>
|
||
<div className="text-lg font-black text-slate-900 font-mono"><Blur>{v.plateNumber}</Blur></div>
|
||
<div className="text-[10px] text-slate-400 mt-0.5">{v.vehicleType} · {v.targetName}</div>
|
||
</div>
|
||
<div className="text-right">
|
||
<div className="text-base font-black text-slate-800">{fmtKm(v.currentYearMileage)}<span className="text-[9px] text-slate-400 ml-0.5">km</span></div>
|
||
<div className="text-[10px] text-slate-400">考核 {fmtKm(v.yearTarget)} km</div>
|
||
<div className="text-[10px] text-slate-400">年度考核剩余 <b className="text-slate-700">{v.daysLeft}</b> 天</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-3 mt-2.5 text-[10px] text-slate-500">
|
||
<span><Blur>{v.customer || '-'}</Blur></span>
|
||
<span>日均 <b className="text-slate-700">{Math.round(v.customerAvgDaily)}</b></span>
|
||
<span>完成 <b className={v.completionRate >= 1 ? 'text-emerald-600' : 'text-rose-500'}>{fmtRate(v.completionRate)}</b></span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Arrow bridge */}
|
||
<div className="flex justify-center -my-3 relative z-10">
|
||
<div className="w-10 h-10 rounded-full bg-slate-800 flex items-center justify-center shadow-lg">
|
||
<ArrowDownUp size={16} className="text-white" />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Replacement vehicle */}
|
||
<div className="bg-white rounded-2xl p-4 border border-emerald-300 shadow-sm">
|
||
<div className="flex items-start justify-between">
|
||
<div>
|
||
<div className="text-lg font-black text-slate-900 font-mono"><Blur>{c.plateNumber}</Blur></div>
|
||
<div className="text-[10px] text-slate-400 mt-0.5">{c.vehicleType} · {c.targetName || '库存'} · {c.region}</div>
|
||
</div>
|
||
<div className="text-right">
|
||
<div className="text-base font-black text-slate-800">{fmtKm(c.totalMileage)}<span className="text-[9px] text-slate-400 ml-0.5">km</span></div>
|
||
<div className="text-[10px] text-slate-400">考核 {c.yearTarget ? fmtKm(c.yearTarget) : '-'} km</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-3 mt-2.5 text-[10px] text-slate-500">
|
||
<span>缺口 <b className="text-rose-500">{fmtKm(c.mileageGap)}</b></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* === Result === */}
|
||
<div className="bg-white rounded-2xl p-4 border border-slate-200 shadow-sm">
|
||
<div className="text-[10px] font-bold text-slate-400 uppercase mb-3">替换后预测</div>
|
||
<div className="flex items-end gap-6">
|
||
<div>
|
||
<div className="text-[9px] text-slate-400 mb-0.5">预测年终里程</div>
|
||
<div className="text-xl font-black text-slate-800">{fmtKm(c.predictedAfterSwap)} <span className="text-[10px] font-normal text-slate-400">km</span></div>
|
||
</div>
|
||
<div>
|
||
<div className="text-[9px] text-slate-400 mb-0.5">考核目标</div>
|
||
<div className="text-xl font-black text-slate-800">{c.yearTarget ? fmtKm(c.yearTarget) : '-'} <span className="text-[10px] font-normal text-slate-400">km</span></div>
|
||
</div>
|
||
<div className={`text-sm font-black px-3 py-1 rounded-lg ${c.canQualifyAfterSwap ? 'bg-emerald-50 text-emerald-600' : 'bg-amber-50 text-amber-600'}`}>
|
||
{c.canQualifyAfterSwap ? '可达标' : '需关注'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
{/* Bottom */}
|
||
<div className="px-5 pb-6 pt-2 flex-shrink-0 bg-[#F0F4F8]">
|
||
<div className="max-w-sm mx-auto space-y-2">
|
||
{alreadyIntervened && (
|
||
<div className="rounded-xl bg-emerald-50 border border-emerald-200 px-3 py-2 text-[11px] text-emerald-700 flex items-center gap-2">
|
||
<CheckCircle size={13} />
|
||
<span>此车已{isExecuted ? '执行干预' : '登记干预'},如需重新干预请先取消。</span>
|
||
</div>
|
||
)}
|
||
{alreadyIntervened ? (
|
||
<button
|
||
onClick={handleCancel}
|
||
disabled={cancelling}
|
||
className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl text-sm font-bold bg-white text-rose-600 border border-rose-200 hover:bg-rose-50 active:scale-[0.98] transition-all cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
<Ban size={16} /> {cancelling ? '取消中...' : '取消干预'}
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={handleSend}
|
||
disabled={sending || sent}
|
||
className={`w-full flex items-center justify-center gap-2 py-3.5 rounded-xl text-sm font-bold transition-all cursor-pointer ${
|
||
sent ? 'bg-emerald-100 text-emerald-600' : 'bg-slate-800 hover:bg-slate-900 text-white active:scale-[0.98] shadow-lg'
|
||
}`}
|
||
>
|
||
{sent ? <><CheckCircle size={16} /> 已登记</> : <><Send size={16} /> 登记干预</>}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|