feat(scheduling): add full-screen SwapPreview for screenshot sharing

New SwapPreview component replaces direct "发送通知" button:
- Full-screen white background for clean screenshots
- Swap diagram: current vehicle → arrow → replacement vehicle
- Replacement reason section
- Post-swap prediction: predicted mileage, target, conclusion
- "发送替换通知" button at bottom
- Candidate button in detail modal changed to "查看替换方案 →"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
kkfluous
2026-04-16 21:43:18 +08:00
parent 0785c78382
commit 6a3a5ba319
2 changed files with 196 additions and 29 deletions

View File

@@ -1,11 +1,11 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
X, MapPin, AlertTriangle, CheckCircle, Send, ArrowDown, ArrowUp, X, MapPin, AlertTriangle, CheckCircle, ArrowDown, ArrowUp, ArrowRight,
} from 'lucide-react'; } from 'lucide-react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { sendNotify } from './api';
import type { SchedulingSuggestion, CandidateVehicle } from './types'; import type { SchedulingSuggestion, CandidateVehicle } from './types';
import Blur from '../../components/Blur'; import Blur from '../../components/Blur';
import SwapPreview from './SwapPreview';
interface Props { interface Props {
suggestion: SchedulingSuggestion; suggestion: SchedulingSuggestion;
@@ -23,34 +23,12 @@ function fmtRate(rate: number): string {
} }
export default function SuggestionDetail({ suggestion: s, onClose, onNotifySuccess }: Props) { export default function SuggestionDetail({ suggestion: s, onClose, onNotifySuccess }: Props) {
const [sending, setSending] = useState(false); const [previewCandidate, setPreviewCandidate] = useState<CandidateVehicle | null>(null);
const [sentPlates, setSentPlates] = useState<Set<string>>(new Set()); const [sentPlates, setSentPlates] = useState<Set<string>>(new Set());
const v = s.currentVehicle; const v = s.currentVehicle;
const isRescue = s.type === 'rescue_hopeless'; const isRescue = s.type === 'rescue_hopeless';
const handleNotify = async (candidate: CandidateVehicle) => {
if (sending || sentPlates.has(candidate.plateNumber)) return;
setSending(true);
try {
const result = await sendNotify({
suggestionId: s.id,
currentPlate: v.plateNumber,
candidatePlate: candidate.plateNumber,
});
if (result.success) {
setSentPlates(prev => new Set(prev).add(candidate.plateNumber));
onNotifySuccess();
} else {
alert(result.message || '发送失败');
}
} catch {
alert('网络错误,请重试');
} finally {
setSending(false);
}
};
return ( return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60] flex items-end sm:items-center justify-center" onClick={onClose}> <div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60] flex items-end sm:items-center justify-center" onClick={onClose}>
<motion.div <motion.div
@@ -178,15 +156,15 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
{/* Action */} {/* Action */}
<div className="px-3 pb-2.5"> <div className="px-3 pb-2.5">
<button <button
onClick={() => handleNotify(c)} onClick={() => setPreviewCandidate(c)}
disabled={sending || sent} disabled={sent}
className={`w-full flex items-center justify-center gap-1.5 text-[11px] font-bold py-2 rounded-lg transition-all cursor-pointer ${ className={`w-full flex items-center justify-center gap-1.5 text-[11px] font-bold py-2 rounded-lg transition-all cursor-pointer ${
sent sent
? 'bg-slate-100 text-slate-400' ? 'bg-emerald-50 text-emerald-600'
: 'bg-slate-800 hover:bg-slate-900 text-white active:scale-[0.98] shadow-sm' : 'bg-slate-800 hover:bg-slate-900 text-white active:scale-[0.98] shadow-sm'
}`} }`}
> >
{sent ? <><CheckCircle size={12} /> </> : <><Send size={12} /> </>} {sent ? <><CheckCircle size={12} /> </> : <> <ArrowRight size={12} /></>}
</button> </button>
</div> </div>
</div> </div>
@@ -206,6 +184,20 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
</button> </button>
</div> </div>
</motion.div> </motion.div>
{/* Full-screen swap preview */}
{previewCandidate && (
<SwapPreview
suggestion={s}
candidate={previewCandidate}
onClose={() => setPreviewCandidate(null)}
onSuccess={() => {
setSentPlates(prev => new Set(prev).add(previewCandidate.plateNumber));
setPreviewCandidate(null);
onNotifySuccess();
}}
/>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,175 @@
import { useState } from 'react';
import { ArrowDown, CheckCircle, Send, X } from 'lucide-react';
import { motion } from 'motion/react';
import { sendNotify } 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 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 v = s.currentVehicle;
const isRescue = s.type === 'rescue_hopeless';
const handleSend = async () => {
if (sending || sent) 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);
}
};
return (
<div className="fixed inset-0 z-[80] bg-white flex flex-col">
{/* Top bar */}
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100 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 — screenshot-friendly, no scroll needed */}
<div className="flex-1 flex flex-col items-center justify-center px-6 py-4 gap-4 overflow-auto">
{/* Swap type badge */}
<div className={`text-xs font-bold px-3 py-1 rounded-full ${
isRescue ? 'bg-blue-50 text-blue-700' : 'bg-orange-50 text-orange-700'
}`}>
{isRescue ? '里程低·换走此车' : '里程高·换下此车'}
</div>
{/* === Swap Diagram === */}
<div className="w-full max-w-sm space-y-3">
{/* Current vehicle (换下/换走) */}
<div className={`rounded-2xl p-4 border ${isRescue ? 'bg-blue-50 border-blue-200' : 'bg-orange-50 border-orange-200'}`}>
<div className="text-[10px] font-bold text-slate-400 uppercase mb-2">
{isRescue ? '换走车辆' : '换下车辆'}
</div>
<div className="flex items-center 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-500 mt-0.5">{v.vehicleType} · {v.targetName}</div>
</div>
<div className="text-right">
<div className="text-sm font-black text-slate-700">{fmtKm(v.currentYearMileage)} <span className="text-[9px] text-slate-400">km</span></div>
<div className="text-[10px] text-slate-400"> {fmtKm(v.yearTarget)} km</div>
</div>
</div>
<div className="flex items-center gap-2 mt-2 text-[10px] text-slate-500">
<span> <b className="text-slate-700"><Blur>{v.customer || '-'}</Blur></b></span>
<span> <b className="text-slate-700">{Math.round(v.customerAvgDaily)}</b> km</span>
<span> <b className={v.completionRate >= 1 ? 'text-emerald-600' : 'text-rose-500'}>{fmtRate(v.completionRate)}</b></span>
</div>
</div>
{/* Arrow */}
<div className="flex justify-center">
<div className="w-10 h-10 rounded-full bg-slate-800 flex items-center justify-center shadow-lg">
<ArrowDown size={18} className="text-white" />
</div>
</div>
{/* Replacement vehicle (换上) */}
<div className="rounded-2xl p-4 border bg-emerald-50 border-emerald-200">
<div className="text-[10px] font-bold text-slate-400 uppercase mb-2">
</div>
<div className="flex items-center 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-500 mt-0.5">{c.vehicleType} · {c.targetName || '库存'}</div>
</div>
<div className="text-right">
<div className="text-sm font-black text-slate-700">{fmtKm(c.totalMileage)} <span className="text-[9px] text-slate-400">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-2 mt-2 text-[10px] text-slate-500">
<span> <b className="text-rose-500">{fmtKm(c.mileageGap)}</b> km</span>
<span> <b className="text-slate-700">{c.region}</b></span>
</div>
</div>
</div>
{/* === Reason === */}
<div className="w-full max-w-sm bg-slate-50 rounded-xl p-3 border border-slate-200">
<div className="text-[10px] font-bold text-slate-500 uppercase mb-1"></div>
<div className="text-xs text-slate-700 leading-relaxed whitespace-pre-line">{s.reason}</div>
</div>
{/* === Conclusion === */}
<div className="w-full max-w-sm bg-emerald-50 rounded-xl p-3 border border-emerald-200">
<div className="text-[10px] font-bold text-emerald-600 uppercase mb-1"></div>
<div className="flex items-center justify-between text-xs">
<span className="text-slate-600">
<Blur>{c.plateNumber}</Blur> <Blur>{v.customer || '-'}</Blur>
</span>
</div>
<div className="flex items-center gap-4 mt-2">
<div>
<div className="text-[9px] text-slate-400"></div>
<div className="text-base font-black text-emerald-700">{fmtKm(c.predictedAfterSwap)} km</div>
</div>
<div>
<div className="text-[9px] text-slate-400"></div>
<div className="text-base font-black text-slate-700">{c.yearTarget ? fmtKm(c.yearTarget) : '-'} km</div>
</div>
<div>
<div className="text-[9px] text-slate-400"></div>
<div className={`text-base font-black ${c.canQualifyAfterSwap ? 'text-emerald-600' : 'text-amber-600'}`}>
{c.canQualifyAfterSwap ? '可达标' : '需关注'}
</div>
</div>
</div>
</div>
</div>
{/* Bottom action */}
<div className="px-6 pb-6 pt-2 flex-shrink-0">
<button
onClick={handleSend}
disabled={sending || sent}
className={`w-full flex items-center justify-center gap-2 py-3 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>
);
}