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>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import type { AdvancedFilters } from './model';
|
||||
|
||||
interface ActiveFilterTagsProps {
|
||||
filters: AdvancedFilters;
|
||||
onChange: Dispatch<SetStateAction<AdvancedFilters>>;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export default function ActiveFilterTags({ filters, onChange, onClear }: ActiveFilterTagsProps) {
|
||||
return (
|
||||
<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={() => onChange(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={() => onChange(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={() => onChange(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={() => onChange(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={() => onChange(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={() => onChange(prev => ({ ...prev, customer: '' }))} /></span>}
|
||||
<button onClick={onClear} className="text-[10px] text-slate-400 hover:text-slate-600 cursor-pointer">清除</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Send } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
interface BatchActionBarProps {
|
||||
selectedCount: number;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export default function BatchActionBar({ selectedCount, onCancel, onConfirm }: BatchActionBarProps) {
|
||||
return (
|
||||
<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">{selectedCount}</span>
|
||||
<span className="text-xs text-slate-400">条</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-xs font-medium text-slate-300 hover:text-white px-2 py-1.5 cursor-pointer transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={selectedCount === 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import Blur from '../../../components/Blur';
|
||||
import type { BatchItem } from './model';
|
||||
|
||||
interface BatchConfirmModalProps {
|
||||
batchItems: BatchItem[];
|
||||
batchInFlight: boolean;
|
||||
batchResultMsg: string | null;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export default function BatchConfirmModal({
|
||||
batchItems,
|
||||
batchInFlight,
|
||||
batchResultMsg,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: BatchConfirmModalProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[70] flex items-end sm:items-center justify-center" onClick={() => !batchInFlight && onClose()}>
|
||||
<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 && onClose()}
|
||||
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={onClose}
|
||||
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={onSubmit}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { CheckSquare, Clock, Download, Filter, RotateCcw } from 'lucide-react';
|
||||
import type { SchedulingTargetOption } from '../types';
|
||||
import { shortTargetName } from './model';
|
||||
|
||||
interface ListHeaderProps {
|
||||
loading: boolean;
|
||||
targets: SchedulingTargetOption[] | undefined;
|
||||
selectedTargetId: number | undefined;
|
||||
selectMode: boolean;
|
||||
showFilter: boolean;
|
||||
activeFilterCount: number;
|
||||
canExport: boolean;
|
||||
onRefresh: () => void;
|
||||
onExport: () => void;
|
||||
onShowHistory: () => void;
|
||||
onToggleSelectMode: () => void;
|
||||
onToggleFilter: () => void;
|
||||
onSelectAll: () => void;
|
||||
onSelectTarget: (targetId: number) => void;
|
||||
}
|
||||
|
||||
export default function ListHeader({
|
||||
loading,
|
||||
targets,
|
||||
selectedTargetId,
|
||||
selectMode,
|
||||
showFilter,
|
||||
activeFilterCount,
|
||||
canExport,
|
||||
onRefresh,
|
||||
onExport,
|
||||
onShowHistory,
|
||||
onToggleSelectMode,
|
||||
onToggleFilter,
|
||||
onSelectAll,
|
||||
onSelectTarget,
|
||||
}: ListHeaderProps) {
|
||||
return (
|
||||
<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={onRefresh} 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={onExport}
|
||||
disabled={!canExport}
|
||||
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={onShowHistory}
|
||||
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={onToggleSelectMode}
|
||||
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={onToggleFilter}
|
||||
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={onSelectAll}
|
||||
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>
|
||||
{targets?.map(target => (
|
||||
<button key={target.id}
|
||||
onClick={() => onSelectTarget(target.id)}
|
||||
className={`px-4 py-1.5 rounded-full text-[11px] font-bold whitespace-nowrap transition-all cursor-pointer ${
|
||||
selectedTargetId === target.id ? 'bg-slate-800 text-white shadow-sm' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{shortTargetName(target.name)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
function Sk({ className }: { className?: string }) {
|
||||
return <div className={`animate-pulse bg-slate-200/70 rounded ${className ?? ''}`} />;
|
||||
}
|
||||
|
||||
export default function ListLoadingSkeleton() {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Activity } from 'lucide-react';
|
||||
import { PageFrame, SkeletonBlock, SurfaceCard } from '../../../components/ui/surface';
|
||||
|
||||
export default function SchedulingSkeleton() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Activity, AlertTriangle, CheckCircle2, SendHorizonal } from 'lucide-react';
|
||||
import { MetricTile } from '../../../components/ui/surface';
|
||||
import type { SchedulingSummary } from '../types';
|
||||
import type { TypeFilter } from './model';
|
||||
|
||||
interface SummaryCardsProps {
|
||||
summary: SchedulingSummary | undefined;
|
||||
typeFilter: TypeFilter;
|
||||
onTypeFilterChange: (filter: TypeFilter) => void;
|
||||
onShowRecentHistory: () => void;
|
||||
}
|
||||
|
||||
export default function SummaryCards({
|
||||
summary,
|
||||
typeFilter,
|
||||
onTypeFilterChange,
|
||||
onShowRecentHistory,
|
||||
}: SummaryCardsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<button type="button" onClick={() => onTypeFilterChange(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={() => onTypeFilterChange(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={() => onTypeFilterChange('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={onShowRecentHistory} className="rounded-2xl">
|
||||
<MetricTile icon={SendHorizonal} label="近期已干预" value={summary?.recentInterventionCount ?? 0} unit="条" helper="最近 7 天 · 点击查看" tone="emerald" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type {
|
||||
CandidateVehicle,
|
||||
SchedulingResponse,
|
||||
SchedulingSuggestion,
|
||||
SchedulingVehicleInfo,
|
||||
} from '../types';
|
||||
import {
|
||||
EMPTY_FILTERS,
|
||||
buildBatchItems,
|
||||
buildFilterOptions,
|
||||
countActiveFilters,
|
||||
filterSuggestions,
|
||||
hasResettableFilters,
|
||||
pickBestCandidate,
|
||||
shortTargetName,
|
||||
} from './model';
|
||||
|
||||
function vehicle(overrides: Partial<SchedulingVehicleInfo> = {}): SchedulingVehicleInfo {
|
||||
return {
|
||||
plateNumber: '粤A00001',
|
||||
targetId: 1,
|
||||
targetName: '40辆4.5T普货',
|
||||
vehicleType: '4.5T普货',
|
||||
totalMileage: 1000,
|
||||
currentYearMileage: 500,
|
||||
completionRate: 50,
|
||||
yearTarget: 1000,
|
||||
region: '广州',
|
||||
province: '广东',
|
||||
customer: '客户甲',
|
||||
department: '业务一部',
|
||||
manager: '负责人甲',
|
||||
customerAvgDaily: 100,
|
||||
customerAvgDaily7d: 100,
|
||||
predictedYearEnd: 900,
|
||||
daysLeft: 10,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function candidate(overrides: Partial<CandidateVehicle> = {}): CandidateVehicle {
|
||||
return {
|
||||
plateNumber: '粤A10001',
|
||||
targetId: 2,
|
||||
targetName: '190辆4.5T冷链',
|
||||
vehicleType: '4.5T冷链',
|
||||
totalMileage: 2000,
|
||||
completionRate: 60,
|
||||
yearTarget: 3000,
|
||||
daysLeft: 20,
|
||||
region: '广州',
|
||||
province: '广东',
|
||||
mileageGap: 1000,
|
||||
predictedAfterSwap: 3200,
|
||||
canQualifyAfterSwap: false,
|
||||
isSameRegion: true,
|
||||
notificationId: null,
|
||||
notificationStatus: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function suggestion(
|
||||
id: string,
|
||||
overrides: Partial<SchedulingSuggestion> = {},
|
||||
): SchedulingSuggestion {
|
||||
return {
|
||||
id,
|
||||
priority: 'high',
|
||||
type: 'replace_qualified',
|
||||
currentVehicle: vehicle(),
|
||||
candidates: [candidate()],
|
||||
reason: { lines: [], conclusion: '保持现有结论' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function response(suggestions: SchedulingSuggestion[]): SchedulingResponse {
|
||||
return {
|
||||
summary: {
|
||||
qualifiedCount: 0,
|
||||
hopelessCount: 0,
|
||||
suggestionCount: suggestions.length,
|
||||
estimatedGain: 0,
|
||||
recentInterventionCount: 0,
|
||||
},
|
||||
suggestions,
|
||||
targets: [],
|
||||
};
|
||||
}
|
||||
|
||||
test('批次简称保持现有车型替换与无法识别时的原文', () => {
|
||||
assert.equal(shortTargetName('40辆4.5T普货'), '40台普货');
|
||||
assert.equal(shortTargetName('190台4.5T冷链车'), '190台冷藏车');
|
||||
assert.equal(shortTargetName('136辆4.5T冷链'), '136台冷藏车');
|
||||
assert.equal(shortTargetName('现代双飞翼'), '现代双飞翼');
|
||||
});
|
||||
|
||||
test('筛选计数覆盖六项,同时保留重置按钮的既有四项判断', () => {
|
||||
const departmentOnly = { ...EMPTY_FILTERS, department: '业务一部' };
|
||||
assert.equal(countActiveFilters(departmentOnly), 1);
|
||||
assert.equal(hasResettableFilters(departmentOnly), false);
|
||||
assert.equal(hasResettableFilters({ ...departmentOnly, customer: '客户甲' }), true);
|
||||
});
|
||||
|
||||
test('候选车优先选择可达标车辆,但已有生效干预时整条跳过', () => {
|
||||
const preferred = candidate({ plateNumber: '粤A10002', canQualifyAfterSwap: true });
|
||||
assert.equal(
|
||||
pickBestCandidate(suggestion('one', { candidates: [candidate(), preferred] }))?.plateNumber,
|
||||
'粤A10002',
|
||||
);
|
||||
assert.equal(
|
||||
pickBestCandidate(suggestion('two', {
|
||||
candidates: [preferred, candidate({ notificationStatus: 'sent' })],
|
||||
})),
|
||||
null,
|
||||
);
|
||||
assert.equal(pickBestCandidate(suggestion('three', { candidates: [] })), null);
|
||||
});
|
||||
|
||||
test('批量条目保持选择顺序并排除不存在或无可用候选的建议', () => {
|
||||
const first = suggestion('first', { candidates: [candidate({ plateNumber: '粤A10001' })] });
|
||||
const second = suggestion('second', {
|
||||
candidates: [candidate({ plateNumber: '粤A10002', notificationStatus: 'executed' })],
|
||||
});
|
||||
const third = suggestion('third', { candidates: [candidate({ plateNumber: '粤A10003' })] });
|
||||
const items = buildBatchItems(response([first, second, third]), new Set(['third', 'missing', 'second', 'first']));
|
||||
assert.deepEqual(items.map(item => item.suggestion.id), ['third', 'first']);
|
||||
assert.deepEqual(items.map(item => item.candidate.plateNumber), ['粤A10003', '粤A10001']);
|
||||
});
|
||||
|
||||
test('筛选选项保持去空、去重和默认字符串排序', () => {
|
||||
const data = response([
|
||||
suggestion('one', { currentVehicle: vehicle({ region: '浙江', vehicleType: '冷链', customer: '乙', department: '二部', manager: '乙' }) }),
|
||||
suggestion('two', { currentVehicle: vehicle({ region: '广东', vehicleType: '普货', customer: '甲', department: '一部', manager: '甲' }) }),
|
||||
suggestion('three', { currentVehicle: vehicle({ region: '广东', vehicleType: '', customer: null, department: null, manager: null }) }),
|
||||
]);
|
||||
assert.deepEqual(buildFilterOptions(data), {
|
||||
regions: ['广东', '浙江'],
|
||||
vehicleTypes: ['冷链', '普货'],
|
||||
customers: ['乙', '甲'],
|
||||
departments: ['一部', '二部'],
|
||||
managers: ['乙', '甲'],
|
||||
});
|
||||
assert.deepEqual(buildFilterOptions(null), {
|
||||
regions: [], vehicleTypes: [], customers: [], departments: [], managers: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('建议列表保持类型、车牌及全部高级筛选条件的交集和原顺序', () => {
|
||||
const data = response([
|
||||
suggestion('keep', {
|
||||
type: 'rescue_hopeless',
|
||||
currentVehicle: vehicle({ plateNumber: '粤AbC123', region: '广东', vehicleType: '冷链', customer: '客户甲', department: '业务一部', manager: '负责人甲' }),
|
||||
}),
|
||||
suggestion('wrong-type', { type: 'replace_qualified' }),
|
||||
suggestion('wrong-manager', {
|
||||
type: 'rescue_hopeless',
|
||||
currentVehicle: vehicle({ plateNumber: '粤ABC999', vehicleType: '冷链', manager: '负责人乙' }),
|
||||
}),
|
||||
]);
|
||||
const filters = {
|
||||
plateSearch: 'ABC',
|
||||
region: '广东',
|
||||
vehicleType: '冷链',
|
||||
customer: '客户甲',
|
||||
department: '业务一部',
|
||||
manager: '负责人甲',
|
||||
};
|
||||
assert.deepEqual(filterSuggestions(data, 'hopeless', filters).map(item => item.id), ['keep']);
|
||||
assert.strictEqual(filterSuggestions(data, 'all', EMPTY_FILTERS), data.suggestions);
|
||||
assert.deepEqual(filterSuggestions(null, 'all', EMPTY_FILTERS), []);
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import type {
|
||||
CandidateVehicle,
|
||||
SchedulingResponse,
|
||||
SchedulingSuggestion,
|
||||
} from '../types';
|
||||
|
||||
export type TypeFilter = 'all' | 'qualified' | 'hopeless';
|
||||
|
||||
export interface AdvancedFilters {
|
||||
plateSearch: string;
|
||||
region: string;
|
||||
vehicleType: string;
|
||||
customer: string;
|
||||
department: string;
|
||||
manager: string;
|
||||
}
|
||||
|
||||
export interface FilterOptions {
|
||||
regions: string[];
|
||||
vehicleTypes: string[];
|
||||
customers: string[];
|
||||
departments: string[];
|
||||
managers: string[];
|
||||
}
|
||||
|
||||
export interface BatchItem {
|
||||
suggestion: SchedulingSuggestion;
|
||||
candidate: CandidateVehicle;
|
||||
}
|
||||
|
||||
export const EMPTY_FILTERS: AdvancedFilters = {
|
||||
plateSearch: '',
|
||||
region: '',
|
||||
vehicleType: '',
|
||||
customer: '',
|
||||
department: '',
|
||||
manager: '',
|
||||
};
|
||||
|
||||
const EMPTY_FILTER_OPTIONS: FilterOptions = {
|
||||
regions: [],
|
||||
vehicleTypes: [],
|
||||
customers: [],
|
||||
departments: [],
|
||||
managers: [],
|
||||
};
|
||||
|
||||
export 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}`;
|
||||
}
|
||||
|
||||
export function hasResettableFilters(filters: AdvancedFilters): boolean {
|
||||
// Preserve the current reset-button condition. Department and manager alone
|
||||
// do not make the reset action visible in the existing interface.
|
||||
return filters.plateSearch !== ''
|
||||
|| filters.region !== ''
|
||||
|| filters.vehicleType !== ''
|
||||
|| filters.customer !== '';
|
||||
}
|
||||
|
||||
export function countActiveFilters(filters: AdvancedFilters): number {
|
||||
return [
|
||||
filters.plateSearch,
|
||||
filters.region,
|
||||
filters.vehicleType,
|
||||
filters.customer,
|
||||
filters.department,
|
||||
filters.manager,
|
||||
].filter(Boolean).length;
|
||||
}
|
||||
|
||||
export function pickBestCandidate(suggestion: SchedulingSuggestion): CandidateVehicle | null {
|
||||
// At most one active intervention is allowed per suggestion. If any
|
||||
// candidate is already intervened, the whole suggestion is skipped.
|
||||
const hasActive = suggestion.candidates.some(
|
||||
candidate => candidate.notificationStatus === 'sent'
|
||||
|| candidate.notificationStatus === 'executed',
|
||||
);
|
||||
if (hasActive) return null;
|
||||
return suggestion.candidates.find(candidate => candidate.canQualifyAfterSwap)
|
||||
?? suggestion.candidates[0]
|
||||
?? null;
|
||||
}
|
||||
|
||||
export function buildBatchItems(
|
||||
data: SchedulingResponse | null,
|
||||
selectedIds: ReadonlySet<string>,
|
||||
): BatchItem[] {
|
||||
if (!data) return [];
|
||||
return [...selectedIds]
|
||||
.map(id => data.suggestions.find(suggestion => suggestion.id === id))
|
||||
.filter((suggestion): suggestion is SchedulingSuggestion => !!suggestion)
|
||||
.map(suggestion => {
|
||||
const candidate = pickBestCandidate(suggestion);
|
||||
if (!candidate) return null;
|
||||
return { suggestion, candidate };
|
||||
})
|
||||
.filter((item): item is BatchItem => !!item);
|
||||
}
|
||||
|
||||
export function buildFilterOptions(data: SchedulingResponse | null): FilterOptions {
|
||||
if (!data) return EMPTY_FILTER_OPTIONS;
|
||||
|
||||
const regions = new Set<string>();
|
||||
const vehicleTypes = new Set<string>();
|
||||
const customers = new Set<string>();
|
||||
const departments = new Set<string>();
|
||||
const managers = new Set<string>();
|
||||
|
||||
for (const suggestion of data.suggestions) {
|
||||
const vehicle = suggestion.currentVehicle;
|
||||
if (vehicle.region) regions.add(vehicle.region);
|
||||
if (vehicle.vehicleType) vehicleTypes.add(vehicle.vehicleType);
|
||||
if (vehicle.customer) customers.add(vehicle.customer);
|
||||
if (vehicle.department) departments.add(vehicle.department);
|
||||
if (vehicle.manager) managers.add(vehicle.manager);
|
||||
}
|
||||
|
||||
return {
|
||||
regions: [...regions].sort(),
|
||||
vehicleTypes: [...vehicleTypes].sort(),
|
||||
customers: [...customers].sort(),
|
||||
departments: [...departments].sort(),
|
||||
managers: [...managers].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
export function filterSuggestions(
|
||||
data: SchedulingResponse | null,
|
||||
typeFilter: TypeFilter,
|
||||
filters: AdvancedFilters,
|
||||
): SchedulingSuggestion[] {
|
||||
if (!data) return [];
|
||||
|
||||
let list = data.suggestions;
|
||||
if (typeFilter === 'qualified') {
|
||||
list = list.filter(suggestion => suggestion.type === 'replace_qualified');
|
||||
}
|
||||
if (typeFilter === 'hopeless') {
|
||||
list = list.filter(suggestion => suggestion.type === 'rescue_hopeless');
|
||||
}
|
||||
if (filters.plateSearch) {
|
||||
const query = filters.plateSearch.toLowerCase();
|
||||
list = list.filter(suggestion => (
|
||||
suggestion.currentVehicle.plateNumber.toLowerCase().includes(query)
|
||||
));
|
||||
}
|
||||
if (filters.region) {
|
||||
list = list.filter(suggestion => suggestion.currentVehicle.region === filters.region);
|
||||
}
|
||||
if (filters.vehicleType) {
|
||||
list = list.filter(suggestion => suggestion.currentVehicle.vehicleType === filters.vehicleType);
|
||||
}
|
||||
if (filters.customer) {
|
||||
list = list.filter(suggestion => suggestion.currentVehicle.customer === filters.customer);
|
||||
}
|
||||
if (filters.department) {
|
||||
list = list.filter(suggestion => suggestion.currentVehicle.department === filters.department);
|
||||
}
|
||||
if (filters.manager) {
|
||||
list = list.filter(suggestion => suggestion.currentVehicle.manager === filters.manager);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
Reference in New Issue
Block a user