refactor: modularize application domains
This commit is contained in:
@@ -1,43 +1,31 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { Activity, AlertTriangle, CheckCircle2, Filter, RotateCcw, X, Search, ChevronDown, CheckSquare, Send, Clock, Download, SendHorizonal } from 'lucide-react';
|
||||
import { Activity, RotateCcw, Search, ChevronDown } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { fetchSuggestions, sendNotifyBatch } from './api';
|
||||
import type { SchedulingResponse, SchedulingSuggestion, CandidateVehicle } from './types';
|
||||
import type { SchedulingResponse, SchedulingSuggestion } from './types';
|
||||
import SuggestionList from './SuggestionList';
|
||||
import SuggestionDetail from './SuggestionDetail';
|
||||
import NotificationHistory from './NotificationHistory';
|
||||
import { exportSuggestionsCsv } from './csv-export';
|
||||
import Blur from '../../components/Blur';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { MetricTile, PageFrame, SkeletonBlock, SurfaceCard } from '../../components/ui/surface';
|
||||
|
||||
type TypeFilter = 'all' | 'qualified' | 'hopeless';
|
||||
|
||||
interface AdvancedFilters {
|
||||
plateSearch: string;
|
||||
region: string;
|
||||
vehicleType: string;
|
||||
customer: string;
|
||||
department: string;
|
||||
manager: string;
|
||||
}
|
||||
|
||||
const EMPTY_FILTERS: AdvancedFilters = { plateSearch: '', region: '', vehicleType: '', customer: '', department: '', manager: '' };
|
||||
|
||||
function shortTargetName(name: string): string {
|
||||
const match = name.match(/(\d+)[辆台](.+)/);
|
||||
if (!match) return name;
|
||||
const count = match[1];
|
||||
let desc = match[2];
|
||||
desc = desc.replace('4.5T普货', '普货');
|
||||
desc = desc.replace('4.5T冷链车', '冷藏车');
|
||||
desc = desc.replace('4.5T冷链', '冷藏车');
|
||||
return `${count}台${desc}`;
|
||||
}
|
||||
|
||||
function hasActiveFilters(f: AdvancedFilters): boolean {
|
||||
return f.plateSearch !== '' || f.region !== '' || f.vehicleType !== '' || f.customer !== '';
|
||||
}
|
||||
import { PageFrame } from '../../components/ui/surface';
|
||||
import ActiveFilterTags from './scheduling-module/ActiveFilterTags';
|
||||
import BatchActionBar from './scheduling-module/BatchActionBar';
|
||||
import BatchConfirmModal from './scheduling-module/BatchConfirmModal';
|
||||
import ListHeader from './scheduling-module/ListHeader';
|
||||
import ListLoadingSkeleton from './scheduling-module/ListLoadingSkeleton';
|
||||
import SchedulingSkeleton from './scheduling-module/SchedulingSkeleton';
|
||||
import SummaryCards from './scheduling-module/SummaryCards';
|
||||
import {
|
||||
EMPTY_FILTERS,
|
||||
buildBatchItems,
|
||||
buildFilterOptions,
|
||||
countActiveFilters,
|
||||
filterSuggestions,
|
||||
hasResettableFilters,
|
||||
type AdvancedFilters,
|
||||
type TypeFilter,
|
||||
} from './scheduling-module/model';
|
||||
|
||||
function FilterSelect({ label, options, value, onChange, placeholder }: {
|
||||
label: string; options: string[]; value: string; onChange: (v: string) => void; placeholder: string;
|
||||
@@ -88,56 +76,6 @@ function FilterSelect({ label, options, value, onChange, placeholder }: {
|
||||
);
|
||||
}
|
||||
|
||||
function Sk({ className }: { className?: string }) {
|
||||
return <div className={`animate-pulse bg-slate-200/70 rounded ${className ?? ''}`} />;
|
||||
}
|
||||
|
||||
function SkeletonPage() {
|
||||
return (
|
||||
<PageFrame
|
||||
title="智能调度工作台"
|
||||
subtitle="自动识别高里程可释放车辆与低里程待救援车辆,形成可登记、可追踪的运营干预建议。"
|
||||
icon={Activity}
|
||||
eyebrow="SCHEDULING OPS"
|
||||
meta="建议生成中 · 正在计算候选车辆"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => <SkeletonBlock key={i} className="h-28" />)}
|
||||
</div>
|
||||
<SurfaceCard>
|
||||
<div className="space-y-3 p-4">
|
||||
<SkeletonBlock className="h-5 w-40" />
|
||||
<div className="flex gap-2">
|
||||
{[0, 1, 2, 3].map(i => <SkeletonBlock key={i} className="h-8 w-24 rounded-full" />)}
|
||||
</div>
|
||||
<div className="divide-y divide-slate-50">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 py-3">
|
||||
<SkeletonBlock className="h-10 w-1 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<SkeletonBlock className="h-3.5 w-48" />
|
||||
<SkeletonBlock className="h-2.5 w-72 max-w-full" />
|
||||
</div>
|
||||
<SkeletonBlock className="h-6 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function pickBestCandidate(s: SchedulingSuggestion): CandidateVehicle | null {
|
||||
// Business rule: at most one active intervention per suggestion. If ANY
|
||||
// candidate is already intervened, skip the whole suggestion in batch flow.
|
||||
const hasActive = s.candidates.some(
|
||||
c => c.notificationStatus === 'sent' || c.notificationStatus === 'executed',
|
||||
);
|
||||
if (hasActive) return null;
|
||||
return s.candidates.find(c => c.canQualifyAfterSwap) ?? s.candidates[0] ?? null;
|
||||
}
|
||||
|
||||
export default function SchedulingModule() {
|
||||
const [data, setData] = useState<SchedulingResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -187,16 +125,7 @@ export default function SchedulingModule() {
|
||||
}, []);
|
||||
|
||||
const batchItems = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return [...selectedIds]
|
||||
.map(id => data.suggestions.find(s => s.id === id))
|
||||
.filter((s): s is SchedulingSuggestion => !!s)
|
||||
.map(s => {
|
||||
const candidate = pickBestCandidate(s);
|
||||
if (!candidate) return null;
|
||||
return { suggestion: s, candidate };
|
||||
})
|
||||
.filter((x): x is { suggestion: SchedulingSuggestion; candidate: CandidateVehicle } => !!x);
|
||||
return buildBatchItems(data, selectedIds);
|
||||
}, [data, selectedIds]);
|
||||
|
||||
const handleBatchSubmit = useCallback(async () => {
|
||||
@@ -222,38 +151,18 @@ export default function SchedulingModule() {
|
||||
}, [batchItems, loadData, exitSelectMode]);
|
||||
|
||||
const filterOptions = useMemo(() => {
|
||||
if (!data) return { regions: [], vehicleTypes: [], customers: [], departments: [], managers: [] };
|
||||
const r = new Set<string>(), t = new Set<string>(), c = new Set<string>(), d = new Set<string>(), m = new Set<string>();
|
||||
for (const s of data.suggestions) {
|
||||
const v = s.currentVehicle;
|
||||
if (v.region) r.add(v.region);
|
||||
if (v.vehicleType) t.add(v.vehicleType);
|
||||
if (v.customer) c.add(v.customer);
|
||||
if (v.department) d.add(v.department);
|
||||
if (v.manager) m.add(v.manager);
|
||||
}
|
||||
return { regions: [...r].sort(), vehicleTypes: [...t].sort(), customers: [...c].sort(), departments: [...d].sort(), managers: [...m].sort() };
|
||||
return buildFilterOptions(data);
|
||||
}, [data]);
|
||||
|
||||
const filteredSuggestions = useMemo(() => {
|
||||
if (!data) return [];
|
||||
let list = data.suggestions;
|
||||
if (typeFilter === 'qualified') list = list.filter(s => s.type === 'replace_qualified');
|
||||
if (typeFilter === 'hopeless') list = list.filter(s => s.type === 'rescue_hopeless');
|
||||
if (filters.plateSearch) { const q = filters.plateSearch.toLowerCase(); list = list.filter(s => s.currentVehicle.plateNumber.toLowerCase().includes(q)); }
|
||||
if (filters.region) list = list.filter(s => s.currentVehicle.region === filters.region);
|
||||
if (filters.vehicleType) list = list.filter(s => s.currentVehicle.vehicleType === filters.vehicleType);
|
||||
if (filters.customer) list = list.filter(s => s.currentVehicle.customer === filters.customer);
|
||||
if (filters.department) list = list.filter(s => s.currentVehicle.department === filters.department);
|
||||
if (filters.manager) list = list.filter(s => s.currentVehicle.manager === filters.manager);
|
||||
return list;
|
||||
return filterSuggestions(data, typeFilter, filters);
|
||||
}, [data, typeFilter, filters]);
|
||||
|
||||
const summary = data?.summary;
|
||||
const activeFilterCount = [filters.plateSearch, filters.region, filters.vehicleType, filters.customer, filters.department, filters.manager].filter(Boolean).length;
|
||||
const activeFilterCount = countActiveFilters(filters);
|
||||
|
||||
// Initial load — full page skeleton
|
||||
if (loading && !data) return <SkeletonPage />;
|
||||
if (loading && !data) return <SchedulingSkeleton />;
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
@@ -271,95 +180,36 @@ export default function SchedulingModule() {
|
||||
>
|
||||
|
||||
{/* ===== Summary Cards ===== */}
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<button type="button" onClick={() => setTypeFilter(typeFilter === 'qualified' ? 'all' : 'qualified')} className={typeFilter === 'qualified' ? 'rounded-2xl ring-2 ring-orange-400 ring-offset-2 ring-offset-[var(--app-bg)]' : 'rounded-2xl'}>
|
||||
<MetricTile icon={CheckCircle2} label="已完成考核目标" value={summary?.qualifiedCount ?? 0} unit="台" helper="换下,腾位给待达标车" tone="amber" />
|
||||
</button>
|
||||
<button type="button" onClick={() => setTypeFilter(typeFilter === 'hopeless' ? 'all' : 'hopeless')} className={typeFilter === 'hopeless' ? 'rounded-2xl ring-2 ring-blue-500 ring-offset-2 ring-offset-[var(--app-bg)]' : 'rounded-2xl'}>
|
||||
<MetricTile icon={AlertTriangle} label="预估无法达标" value={summary?.hopelessCount ?? 0} unit="台" helper="换走,换上快达标的车" tone="blue" />
|
||||
</button>
|
||||
<button type="button" onClick={() => setTypeFilter('all')} className={typeFilter === 'all' ? 'rounded-2xl ring-2 ring-slate-700 ring-offset-2 ring-offset-[var(--app-bg)]' : 'rounded-2xl'}>
|
||||
<MetricTile icon={Activity} label="替换建议" value={summary?.suggestionCount ?? 0} unit="条" helper={`执行后预计 +${summary?.estimatedGain ?? 0} 台达标`} tone="slate" />
|
||||
</button>
|
||||
<button type="button" onClick={() => { setShowHistory(true); setHistoryRecentOnly(true); }} className="rounded-2xl">
|
||||
<MetricTile icon={SendHorizonal} label="近期已干预" value={summary?.recentInterventionCount ?? 0} unit="条" helper="最近 7 天 · 点击查看" tone="emerald" />
|
||||
</button>
|
||||
</div>
|
||||
<SummaryCards
|
||||
summary={summary}
|
||||
typeFilter={typeFilter}
|
||||
onTypeFilterChange={setTypeFilter}
|
||||
onShowRecentHistory={() => { setShowHistory(true); setHistoryRecentOnly(true); }}
|
||||
/>
|
||||
|
||||
{/* ===== List Card ===== */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200/60 shadow-sm overflow-hidden">
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-slate-100">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-bold text-slate-900">智能调度干预清单</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={loadData} disabled={loading}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer">
|
||||
<RotateCcw size={15} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => exportSuggestionsCsv(filteredSuggestions)}
|
||||
disabled={filteredSuggestions.length === 0}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="导出 CSV"
|
||||
>
|
||||
<Download size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowHistory(true); setHistoryRecentOnly(false); }}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer"
|
||||
title="调度记录"
|
||||
>
|
||||
<Clock size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectMode) exitSelectMode();
|
||||
else { setSelectMode(true); setSelectedSuggestion(null); }
|
||||
}}
|
||||
className={`relative p-1.5 transition-colors rounded-lg cursor-pointer ${
|
||||
selectMode ? 'text-blue-600 bg-blue-50' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
title={selectMode ? '退出多选' : '多选模式'}
|
||||
>
|
||||
<CheckSquare size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowFilter(!showFilter); setTempFilters(filters); }}
|
||||
className={`relative p-1.5 transition-colors rounded-lg cursor-pointer ${
|
||||
showFilter || activeFilterCount > 0 ? 'text-blue-600 bg-blue-50' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<Filter size={15} />
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-blue-600 text-white text-[8px] font-bold rounded-full flex items-center justify-center">{activeFilterCount}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 overflow-x-auto no-scrollbar">
|
||||
<button
|
||||
onClick={() => { setSelectedTargetId(undefined); setTypeFilter('all'); }}
|
||||
className={`px-4 py-1.5 rounded-full text-[11px] font-bold whitespace-nowrap transition-all cursor-pointer ${
|
||||
selectedTargetId === undefined ? 'bg-slate-800 text-white shadow-sm' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
全部批次
|
||||
</button>
|
||||
{data?.targets.map(t => (
|
||||
<button key={t.id}
|
||||
onClick={() => { setSelectedTargetId(t.id); setTypeFilter('all'); }}
|
||||
className={`px-4 py-1.5 rounded-full text-[11px] font-bold whitespace-nowrap transition-all cursor-pointer ${
|
||||
selectedTargetId === t.id ? 'bg-slate-800 text-white shadow-sm' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{shortTargetName(t.name)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ListHeader
|
||||
loading={loading}
|
||||
targets={data?.targets}
|
||||
selectedTargetId={selectedTargetId}
|
||||
selectMode={selectMode}
|
||||
showFilter={showFilter}
|
||||
activeFilterCount={activeFilterCount}
|
||||
canExport={filteredSuggestions.length > 0}
|
||||
onRefresh={loadData}
|
||||
onExport={() => exportSuggestionsCsv(filteredSuggestions)}
|
||||
onShowHistory={() => { setShowHistory(true); setHistoryRecentOnly(false); }}
|
||||
onToggleSelectMode={() => {
|
||||
if (selectMode) exitSelectMode();
|
||||
else { setSelectMode(true); setSelectedSuggestion(null); }
|
||||
}}
|
||||
onToggleFilter={() => { setShowFilter(!showFilter); setTempFilters(filters); }}
|
||||
onSelectAll={() => { setSelectedTargetId(undefined); setTypeFilter('all'); }}
|
||||
onSelectTarget={targetId => { setSelectedTargetId(targetId); setTypeFilter('all'); }}
|
||||
/>
|
||||
|
||||
{/* Filter Panel */}
|
||||
<AnimatePresence>
|
||||
@@ -368,7 +218,7 @@ export default function SchedulingModule() {
|
||||
<div className="px-4 py-4 bg-slate-50/60 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-slate-700">高级筛选</span>
|
||||
{hasActiveFilters(tempFilters) && (
|
||||
{hasResettableFilters(tempFilters) && (
|
||||
<button onClick={() => setTempFilters(EMPTY_FILTERS)} className="text-[10px] text-slate-400 hover:text-slate-600 cursor-pointer">重置</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -400,16 +250,11 @@ export default function SchedulingModule() {
|
||||
|
||||
{/* Active filter tags */}
|
||||
{activeFilterCount > 0 && !showFilter && (
|
||||
<div className="px-4 py-2 border-b border-slate-100 flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[10px] text-slate-400">筛选:</span>
|
||||
{filters.plateSearch && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">车牌 "{filters.plateSearch}" <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, plateSearch: '' }))} /></span>}
|
||||
{filters.region && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.region} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, region: '' }))} /></span>}
|
||||
{filters.vehicleType && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.vehicleType} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, vehicleType: '' }))} /></span>}
|
||||
{filters.department && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.department} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, department: '' }))} /></span>}
|
||||
{filters.manager && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.manager} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, manager: '' }))} /></span>}
|
||||
{filters.customer && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.customer} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, customer: '' }))} /></span>}
|
||||
<button onClick={() => setFilters(EMPTY_FILTERS)} className="text-[10px] text-slate-400 hover:text-slate-600 cursor-pointer">清除</button>
|
||||
</div>
|
||||
<ActiveFilterTags
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
onClear={() => setFilters(EMPTY_FILTERS)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(activeFilterCount > 0 || typeFilter !== 'all') && (
|
||||
@@ -418,26 +263,7 @@ export default function SchedulingModule() {
|
||||
|
||||
{loading ? (
|
||||
/* List skeleton while refreshing */
|
||||
<div className="divide-y divide-slate-50">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="px-4 py-3 flex items-center gap-3">
|
||||
<Sk className="w-1 h-10 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sk className="h-3.5 w-20" />
|
||||
<Sk className="h-3 w-10 rounded-full" />
|
||||
<Sk className="h-3 w-14" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Sk className="h-2.5 w-28" />
|
||||
<Sk className="h-2.5 w-16" />
|
||||
<Sk className="h-2.5 w-14" />
|
||||
</div>
|
||||
</div>
|
||||
<Sk className="h-4 w-8" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ListLoadingSkeleton />
|
||||
) : (
|
||||
<SuggestionList
|
||||
suggestions={filteredSuggestions}
|
||||
@@ -465,97 +291,23 @@ export default function SchedulingModule() {
|
||||
{/* Batch action bar */}
|
||||
<AnimatePresence>
|
||||
{selectMode && (
|
||||
<motion.div
|
||||
initial={{ y: 80, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 80, opacity: 0 }}
|
||||
className="fixed bottom-4 left-3 right-3 md:left-auto md:right-6 md:bottom-6 md:w-[360px] z-40 bg-slate-900 text-white rounded-2xl shadow-2xl px-4 py-3 flex items-center justify-between gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">已选</span>
|
||||
<span className="text-lg font-black">{selectedIds.size}</span>
|
||||
<span className="text-xs text-slate-400">条</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={exitSelectMode}
|
||||
className="text-xs font-medium text-slate-300 hover:text-white px-2 py-1.5 cursor-pointer transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowBatchConfirm(true)}
|
||||
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} /> 批量干预
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
<BatchActionBar
|
||||
selectedCount={selectedIds.size}
|
||||
onCancel={exitSelectMode}
|
||||
onConfirm={() => setShowBatchConfirm(true)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Batch confirmation modal */}
|
||||
{showBatchConfirm && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[70] flex items-end sm:items-center justify-center" onClick={() => !batchInFlight && setShowBatchConfirm(false)}>
|
||||
<motion.div
|
||||
initial={{ y: 40, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
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>
|
||||
<button
|
||||
onClick={() => !batchInFlight && setShowBatchConfirm(false)}
|
||||
disabled={batchInFlight}
|
||||
className="text-slate-400 hover:text-white transition-colors p-1 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</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> 条干预,已排除无可用候选车的建议。
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{batchItems.map(({ suggestion, candidate }) => (
|
||||
<div key={suggestion.id} className="text-[11px] bg-slate-50 rounded-lg px-3 py-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="font-mono font-bold text-slate-900"><Blur>{suggestion.currentVehicle.plateNumber}</Blur></span>
|
||||
<span className="text-slate-400">→</span>
|
||||
<span className="font-mono font-bold text-blue-700"><Blur>{candidate.plateNumber}</Blur></span>
|
||||
</div>
|
||||
{candidate.canQualifyAfterSwap ? (
|
||||
<span className="text-emerald-600 text-[9px] font-bold flex-shrink-0">可达标</span>
|
||||
) : (
|
||||
<span className="text-amber-500 text-[9px] font-bold flex-shrink-0">需关注</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{batchResultMsg && (
|
||||
<p className="mt-3 text-[11px] text-slate-500">{batchResultMsg}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-slate-100 px-4 py-3 flex-shrink-0 flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowBatchConfirm(false)}
|
||||
disabled={batchInFlight}
|
||||
className="flex-1 py-2 text-xs font-bold text-slate-500 bg-slate-50 hover:bg-slate-100 rounded-lg cursor-pointer disabled:opacity-50 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBatchSubmit}
|
||||
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} 条`}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
<BatchConfirmModal
|
||||
batchItems={batchItems}
|
||||
batchInFlight={batchInFlight}
|
||||
batchResultMsg={batchResultMsg}
|
||||
onClose={() => setShowBatchConfirm(false)}
|
||||
onSubmit={handleBatchSubmit}
|
||||
/>
|
||||
)}
|
||||
<RotatingFooterHint className="pb-4" />
|
||||
</PageFrame>
|
||||
|
||||
Reference in New Issue
Block a user