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) => {
|
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' });
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ const HEADERS = [
|
|||||||
'候选替换后预估(km)',
|
'候选替换后预估(km)',
|
||||||
'候选可达标',
|
'候选可达标',
|
||||||
'候选区域',
|
'候选区域',
|
||||||
'通知状态',
|
'干预状态',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function buildSuggestionsCsv(suggestions: SchedulingSuggestion[]): string {
|
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_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> {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user