feat(scheduling): rename 通知→干预, allow drill-in on intervened items
- Globally rename user-facing 通知 → 干预 (list badge, detail button, batch modal, CSV header, server response messages, db table comment) - 已干预 row in detail is now clickable — opens SwapPreview which shows a read-only summary plus a 取消干预 action (PATCH notify /:id with status=cancelled). Sending is blocked while already intervened. - Selected suggestion now follows the latest data snapshot so status changes from within the detail flow propagate immediately. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -81,7 +81,7 @@ export default function NotificationHistory({ onClose, onChange }: Props) {
|
||||
};
|
||||
|
||||
const handleCancel = async (rec: NotificationRecord) => {
|
||||
if (!confirm(`确定取消 ${rec.currentPlate} → ${rec.candidatePlate} 的替换通知?`)) return;
|
||||
if (!confirm(`确定取消 ${rec.currentPlate} → ${rec.candidatePlate} 的干预?`)) return;
|
||||
setMutatingId(rec.id);
|
||||
try {
|
||||
await updateNotification(rec.id, { status: 'cancelled' });
|
||||
|
||||
@@ -177,11 +177,13 @@ export default function SchedulingModule() {
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
const handleNotifySuccess = useCallback(() => { loadData(); }, [loadData]);
|
||||
|
||||
// Close detail modal if selected suggestion is filtered out or no longer exists
|
||||
// Keep selectedSuggestion synced with latest data so candidate notification
|
||||
// status changes (登记 / 取消干预) propagate into the open detail modal.
|
||||
useEffect(() => {
|
||||
if (!selectedSuggestion || !data) return;
|
||||
const stillExists = data.suggestions.some(s => s.id === selectedSuggestion.id);
|
||||
if (!stillExists) setSelectedSuggestion(null);
|
||||
const fresh = data.suggestions.find(s => s.id === selectedSuggestion.id);
|
||||
if (!fresh) setSelectedSuggestion(null);
|
||||
else if (fresh !== selectedSuggestion) setSelectedSuggestion(fresh);
|
||||
}, [data, selectedSuggestion]);
|
||||
|
||||
const toggleSelect = useCallback((id: string) => {
|
||||
@@ -227,7 +229,7 @@ export default function SchedulingModule() {
|
||||
exitSelectMode();
|
||||
} catch (e) {
|
||||
console.error('batch notify failed:', e);
|
||||
setBatchResultMsg('批量通知失败,请重试');
|
||||
setBatchResultMsg('批量干预失败,请重试');
|
||||
} finally {
|
||||
setBatchInFlight(false);
|
||||
}
|
||||
@@ -534,7 +536,7 @@ export default function SchedulingModule() {
|
||||
disabled={selectedIds.size === 0}
|
||||
className="flex items-center gap-1.5 text-xs font-bold bg-blue-600 hover:bg-blue-500 disabled:bg-slate-700 disabled:text-slate-400 text-white px-3 py-1.5 rounded-lg cursor-pointer disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Send size={12} /> 批量通知
|
||||
<Send size={12} /> 批量干预
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -551,7 +553,7 @@ export default function SchedulingModule() {
|
||||
className="bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl w-full sm:max-w-md overflow-hidden flex flex-col max-h-[80vh] sm:mx-4"
|
||||
>
|
||||
<div className="bg-slate-800 px-4 py-3 flex items-center justify-between flex-shrink-0">
|
||||
<span className="text-white font-bold text-sm">确认批量通知</span>
|
||||
<span className="text-white font-bold text-sm">确认批量干预</span>
|
||||
<button
|
||||
onClick={() => !batchInFlight && setShowBatchConfirm(false)}
|
||||
disabled={batchInFlight}
|
||||
@@ -562,7 +564,7 @@ export default function SchedulingModule() {
|
||||
</div>
|
||||
<div className="px-4 py-3 overflow-y-auto flex-1">
|
||||
<p className="text-xs text-slate-500 mb-3">
|
||||
将发送 <span className="font-bold text-slate-800">{batchItems.length}</span> 条替换通知,已排除无可用候选车的建议。
|
||||
将登记 <span className="font-bold text-slate-800">{batchItems.length}</span> 条干预,已排除无可用候选车的建议。
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{batchItems.map(({ suggestion, candidate }) => (
|
||||
@@ -597,7 +599,7 @@ export default function SchedulingModule() {
|
||||
disabled={batchInFlight || batchItems.length === 0}
|
||||
className="flex-1 py-2 text-xs font-bold text-white bg-blue-600 hover:bg-blue-500 rounded-lg cursor-pointer disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{batchInFlight ? '发送中...' : `确认发送 ${batchItems.length} 条`}
|
||||
{batchInFlight ? '登记中...' : `确认登记 ${batchItems.length} 条`}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -113,14 +113,13 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
|
||||
<div className="px-3 pb-2.5">
|
||||
<button
|
||||
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-emerald-50 text-emerald-600'
|
||||
? 'bg-emerald-50 hover:bg-emerald-100 text-emerald-700 border border-emerald-200'
|
||||
: 'bg-slate-800 hover:bg-slate-900 text-white active:scale-[0.98] shadow-sm'
|
||||
}`}
|
||||
>
|
||||
{sent ? <><CheckCircle size={12} /> 已通知</> : <>查看替换方案 <ArrowRight size={12} /></>}
|
||||
{sent ? <><CheckCircle size={12} /> 已干预 · 查看 <ArrowRight size={12} /></> : <>查看替换方案 <ArrowRight size={12} /></>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -133,7 +133,7 @@ export default function SuggestionList({ suggestions, onSelect, selectMode = fal
|
||||
<span className="text-[9px] text-slate-400">{v.region}</span>
|
||||
{notified && (
|
||||
<span className="text-[9px] font-bold text-emerald-600 bg-emerald-50 px-1.5 py-0.5 rounded flex items-center gap-0.5">
|
||||
<CheckCircle size={9} /> 已通知
|
||||
<CheckCircle size={9} /> 已干预
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { ArrowDownUp, CheckCircle, Send, X } from 'lucide-react';
|
||||
import { sendNotify } from './api';
|
||||
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';
|
||||
|
||||
@@ -23,10 +23,15 @@ function fmtRate(rate: number): string {
|
||||
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) return;
|
||||
if (sending || sent || alreadyIntervened) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const result = await sendNotify({ suggestionId: s.id, currentPlate: v.plateNumber, candidatePlate: c.plateNumber });
|
||||
@@ -34,6 +39,21 @@ export default function SwapPreview({ suggestion: s, candidate: c, onClose, onSu
|
||||
} 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 */}
|
||||
@@ -115,7 +135,22 @@ export default function SwapPreview({ suggestion: s, candidate: c, onClose, onSu
|
||||
|
||||
{/* Bottom */}
|
||||
<div className="px-5 pb-6 pt-2 flex-shrink-0 bg-[#F0F4F8]">
|
||||
<div className="max-w-sm mx-auto">
|
||||
<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}
|
||||
@@ -123,8 +158,9 @@ export default function SwapPreview({ suggestion: s, candidate: c, onClose, onSu
|
||||
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} /> 发送替换通知</>}
|
||||
{sent ? <><CheckCircle size={16} /> 已登记</> : <><Send size={16} /> 登记干预</>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,7 @@ const HEADERS = [
|
||||
'候选替换后预估(km)',
|
||||
'候选可达标',
|
||||
'候选区域',
|
||||
'通知状态',
|
||||
'干预状态',
|
||||
] as const;
|
||||
|
||||
export function buildSuggestionsCsv(suggestions: SchedulingSuggestion[]): string {
|
||||
|
||||
@@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS tab_scheduling_notifications (
|
||||
INDEX idx_candidate_plate (candidate_plate),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='智能调度通知/执行记录'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='智能调度干预/执行记录'
|
||||
`;
|
||||
|
||||
export async function ensureSchedulingTables(): Promise<void> {
|
||||
|
||||
@@ -119,12 +119,12 @@ app.post('/', async (c) => {
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
message: `替换通知已发送:${currentPlate} → ${candidatePlate}`,
|
||||
message: `干预已登记:${currentPlate} → ${candidatePlate}`,
|
||||
record: result,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
console.error('scheduling notify error:', e);
|
||||
return c.json({ success: false, message: '发送通知失败' }, 500);
|
||||
return c.json({ success: false, message: '登记干预失败' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -166,12 +166,12 @@ app.post('/batch', async (c) => {
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
message: `批量通知:成功 ${result.success},跳过 ${result.skipped},失败 ${result.failed}`,
|
||||
message: `批量干预:成功 ${result.success},跳过 ${result.skipped},失败 ${result.failed}`,
|
||||
result,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
console.error('scheduling batch notify error:', e);
|
||||
return c.json({ success: false, message: '批量通知失败' }, 500);
|
||||
return c.json({ success: false, message: '批量干预失败' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user