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:
kkfluous
2026-04-17 09:14:53 +08:00
parent 1d9f4cb43d
commit 210db7f8ff
8 changed files with 69 additions and 32 deletions

View File

@@ -81,7 +81,7 @@ export default function NotificationHistory({ onClose, onChange }: Props) {
}; };
const handleCancel = async (rec: NotificationRecord) => { const handleCancel = async (rec: NotificationRecord) => {
if (!confirm(`确定取消 ${rec.currentPlate}${rec.candidatePlate}替换通知`)) return; if (!confirm(`确定取消 ${rec.currentPlate}${rec.candidatePlate}干预`)) return;
setMutatingId(rec.id); setMutatingId(rec.id);
try { try {
await updateNotification(rec.id, { status: 'cancelled' }); await updateNotification(rec.id, { status: 'cancelled' });

View File

@@ -177,11 +177,13 @@ export default function SchedulingModule() {
useEffect(() => { loadData(); }, [loadData]); useEffect(() => { loadData(); }, [loadData]);
const handleNotifySuccess = useCallback(() => { 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(() => { useEffect(() => {
if (!selectedSuggestion || !data) return; if (!selectedSuggestion || !data) return;
const stillExists = data.suggestions.some(s => s.id === selectedSuggestion.id); const fresh = data.suggestions.find(s => s.id === selectedSuggestion.id);
if (!stillExists) setSelectedSuggestion(null); if (!fresh) setSelectedSuggestion(null);
else if (fresh !== selectedSuggestion) setSelectedSuggestion(fresh);
}, [data, selectedSuggestion]); }, [data, selectedSuggestion]);
const toggleSelect = useCallback((id: string) => { const toggleSelect = useCallback((id: string) => {
@@ -227,7 +229,7 @@ export default function SchedulingModule() {
exitSelectMode(); exitSelectMode();
} catch (e) { } catch (e) {
console.error('batch notify failed:', e); console.error('batch notify failed:', e);
setBatchResultMsg('批量通知失败,请重试'); setBatchResultMsg('批量干预失败,请重试');
} finally { } finally {
setBatchInFlight(false); setBatchInFlight(false);
} }
@@ -534,7 +536,7 @@ export default function SchedulingModule() {
disabled={selectedIds.size === 0} 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" 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> </button>
</div> </div>
</motion.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" 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"> <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 <button
onClick={() => !batchInFlight && setShowBatchConfirm(false)} onClick={() => !batchInFlight && setShowBatchConfirm(false)}
disabled={batchInFlight} disabled={batchInFlight}
@@ -562,7 +564,7 @@ export default function SchedulingModule() {
</div> </div>
<div className="px-4 py-3 overflow-y-auto flex-1"> <div className="px-4 py-3 overflow-y-auto flex-1">
<p className="text-xs text-slate-500 mb-3"> <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> </p>
<div className="space-y-2"> <div className="space-y-2">
{batchItems.map(({ suggestion, candidate }) => ( {batchItems.map(({ suggestion, candidate }) => (
@@ -597,7 +599,7 @@ export default function SchedulingModule() {
disabled={batchInFlight || batchItems.length === 0} 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" 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> </button>
</div> </div>
</motion.div> </motion.div>

View File

@@ -113,14 +113,13 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
<div className="px-3 pb-2.5"> <div className="px-3 pb-2.5">
<button <button
onClick={() => setPreviewCandidate(c)} 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 ${ 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-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' : '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> </button>
</div> </div>
</div> </div>

View File

@@ -133,7 +133,7 @@ export default function SuggestionList({ suggestions, onSelect, selectMode = fal
<span className="text-[9px] text-slate-400">{v.region}</span> <span className="text-[9px] text-slate-400">{v.region}</span>
{notified && ( {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"> <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> </span>
)} )}
</div> </div>

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { ArrowDownUp, CheckCircle, Send, X } from 'lucide-react'; import { ArrowDownUp, CheckCircle, Send, X, Ban } from 'lucide-react';
import { sendNotify } from './api'; import { sendNotify, updateNotification } from './api';
import type { SchedulingSuggestion, CandidateVehicle } from './types'; import type { SchedulingSuggestion, CandidateVehicle } from './types';
import Blur from '../../components/Blur'; 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) { export default function SwapPreview({ suggestion: s, candidate: c, onClose, onSuccess }: Props) {
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false); const [sent, setSent] = useState(false);
const [cancelling, setCancelling] = useState(false);
const v = s.currentVehicle; const v = s.currentVehicle;
const alreadyIntervened =
!sent && (c.notificationStatus === 'sent' || c.notificationStatus === 'executed');
const isExecuted = c.notificationStatus === 'executed';
const handleSend = async () => { const handleSend = async () => {
if (sending || sent) return; if (sending || sent || alreadyIntervened) return;
setSending(true); setSending(true);
try { try {
const result = await sendNotify({ suggestionId: s.id, currentPlate: v.plateNumber, candidatePlate: c.plateNumber }); 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); } } 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 ( return (
<div className="fixed inset-0 z-[80] bg-[#F0F4F8] flex flex-col"> <div className="fixed inset-0 z-[80] bg-[#F0F4F8] flex flex-col">
{/* Header */} {/* Header */}
@@ -115,7 +135,22 @@ export default function SwapPreview({ suggestion: s, candidate: c, onClose, onSu
{/* Bottom */} {/* Bottom */}
<div className="px-5 pb-6 pt-2 flex-shrink-0 bg-[#F0F4F8]"> <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 <button
onClick={handleSend} onClick={handleSend}
disabled={sending || sent} 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 ? '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> </button>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -41,7 +41,7 @@ const HEADERS = [
'候选替换后预估(km)', '候选替换后预估(km)',
'候选可达标', '候选可达标',
'候选区域', '候选区域',
'通知状态', '干预状态',
] as const; ] as const;
export function buildSuggestionsCsv(suggestions: SchedulingSuggestion[]): string { export function buildSuggestionsCsv(suggestions: SchedulingSuggestion[]): string {

View File

@@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS tab_scheduling_notifications (
INDEX idx_candidate_plate (candidate_plate), INDEX idx_candidate_plate (candidate_plate),
INDEX idx_status (status), INDEX idx_status (status),
INDEX idx_created_at (created_at) INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='智能调度通知/执行记录' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='智能调度干预/执行记录'
`; `;
export async function ensureSchedulingTables(): Promise<void> { export async function ensureSchedulingTables(): Promise<void> {

View File

@@ -119,12 +119,12 @@ app.post('/', async (c) => {
return c.json({ return c.json({
success: true, success: true,
message: `替换通知已发送${currentPlate}${candidatePlate}`, message: `干预已登记${currentPlate}${candidatePlate}`,
record: result, record: result,
}); });
} catch (e: unknown) { } catch (e: unknown) {
console.error('scheduling notify error:', e); 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({ return c.json({
success: true, success: true,
message: `批量通知:成功 ${result.success},跳过 ${result.skipped},失败 ${result.failed}`, message: `批量干预:成功 ${result.success},跳过 ${result.skipped},失败 ${result.failed}`,
result, result,
}); });
} catch (e: unknown) { } catch (e: unknown) {
console.error('scheduling batch notify error:', e); console.error('scheduling batch notify error:', e);
return c.json({ success: false, message: '批量通知失败' }, 500); return c.json({ success: false, message: '批量干预失败' }, 500);
} }
}); });