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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
175
src/modules/scheduling/SwapPreview.tsx
Normal file
175
src/modules/scheduling/SwapPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user