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 {
X, MapPin, AlertTriangle, CheckCircle, Send, ArrowDown, ArrowUp,
X, MapPin, AlertTriangle, CheckCircle, ArrowDown, ArrowUp, ArrowRight,
} from 'lucide-react';
import { motion } from 'motion/react';
import { sendNotify } from './api';
import type { SchedulingSuggestion, CandidateVehicle } from './types';
import Blur from '../../components/Blur';
import SwapPreview from './SwapPreview';
interface Props {
suggestion: SchedulingSuggestion;
@@ -23,34 +23,12 @@ function fmtRate(rate: number): string {
}
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 v = s.currentVehicle;
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 (
<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
@@ -178,15 +156,15 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
{/* Action */}
<div className="px-3 pb-2.5">
<button
onClick={() => handleNotify(c)}
disabled={sending || sent}
onClick={() => setPreviewCandidate(c)}
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 ${
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'
}`}
>
{sent ? <><CheckCircle size={12} /> </> : <><Send size={12} /> </>}
{sent ? <><CheckCircle size={12} /> </> : <> <ArrowRight size={12} /></>}
</button>
</div>
</div>
@@ -206,6 +184,20 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
</button>
</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>
);
}