feat(scheduling): make summary cards clickable filters + refine color scheme
- Cards filter suggestions by type (已达标/无望达标/全部) - Toggle: click active card again to reset to all - Default: white bg + gray border; active: colored bg + ring - Batch selector: dark pills instead of blue - Refresh button moved into list header - Reset type filter when switching batch Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,12 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { Activity, AlertTriangle, CheckCircle, TrendingUp, RotateCcw } from 'lucide-react';
|
import { RotateCcw } from 'lucide-react';
|
||||||
import { motion } from 'motion/react';
|
|
||||||
import { fetchSuggestions } from './api';
|
import { fetchSuggestions } from './api';
|
||||||
import type { SchedulingResponse, SchedulingSuggestion } from './types';
|
import type { SchedulingResponse, SchedulingSuggestion } from './types';
|
||||||
import SuggestionList from './SuggestionList';
|
import SuggestionList from './SuggestionList';
|
||||||
import SuggestionDetail from './SuggestionDetail';
|
import SuggestionDetail from './SuggestionDetail';
|
||||||
|
|
||||||
|
type TypeFilter = 'all' | 'qualified' | 'hopeless';
|
||||||
|
|
||||||
function shortTargetName(name: string): string {
|
function shortTargetName(name: string): string {
|
||||||
const match = name.match(/(\d+)[辆台](.+)/);
|
const match = name.match(/(\d+)[辆台](.+)/);
|
||||||
if (!match) return name;
|
if (!match) return name;
|
||||||
@@ -22,6 +23,7 @@ export default function SchedulingModule() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [selectedTargetId, setSelectedTargetId] = useState<number | undefined>(undefined);
|
const [selectedTargetId, setSelectedTargetId] = useState<number | undefined>(undefined);
|
||||||
const [selectedSuggestion, setSelectedSuggestion] = useState<SchedulingSuggestion | null>(null);
|
const [selectedSuggestion, setSelectedSuggestion] = useState<SchedulingSuggestion | null>(null);
|
||||||
|
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -33,13 +35,61 @@ export default function SchedulingModule() {
|
|||||||
}
|
}
|
||||||
}, [selectedTargetId]);
|
}, [selectedTargetId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { loadData(); }, [loadData]);
|
||||||
loadData();
|
|
||||||
}, [loadData]);
|
|
||||||
|
|
||||||
const handleNotifySuccess = useCallback(() => {
|
const handleNotifySuccess = useCallback(() => { loadData(); }, [loadData]);
|
||||||
loadData();
|
|
||||||
}, [loadData]);
|
const filteredSuggestions = useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
if (typeFilter === 'qualified') return data.suggestions.filter(s => s.type === 'replace_qualified');
|
||||||
|
if (typeFilter === 'hopeless') return data.suggestions.filter(s => s.type === 'rescue_hopeless');
|
||||||
|
return data.suggestions;
|
||||||
|
}, [data, typeFilter]);
|
||||||
|
|
||||||
|
const cardConfigs = [
|
||||||
|
{
|
||||||
|
key: 'qualified' as TypeFilter,
|
||||||
|
label: '已达标',
|
||||||
|
count: data?.summary.qualifiedCount ?? 0,
|
||||||
|
unit: '台',
|
||||||
|
sub: null,
|
||||||
|
// Muted teal/green
|
||||||
|
idle: 'border-slate-200 bg-white',
|
||||||
|
active: 'border-emerald-500 bg-emerald-50 ring-1 ring-emerald-500/20',
|
||||||
|
labelColor: 'text-slate-500',
|
||||||
|
labelActive: 'text-emerald-600',
|
||||||
|
numColor: 'text-slate-800',
|
||||||
|
numActive: 'text-emerald-700',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'hopeless' as TypeFilter,
|
||||||
|
label: '无望达标',
|
||||||
|
count: data?.summary.hopelessCount ?? 0,
|
||||||
|
unit: '台',
|
||||||
|
sub: null,
|
||||||
|
// Warm red
|
||||||
|
idle: 'border-slate-200 bg-white',
|
||||||
|
active: 'border-rose-500 bg-rose-50 ring-1 ring-rose-500/20',
|
||||||
|
labelColor: 'text-slate-500',
|
||||||
|
labelActive: 'text-rose-600',
|
||||||
|
numColor: 'text-slate-800',
|
||||||
|
numActive: 'text-rose-700',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'all' as TypeFilter,
|
||||||
|
label: '可干预',
|
||||||
|
count: data?.summary.suggestionCount ?? 0,
|
||||||
|
unit: '条',
|
||||||
|
sub: data ? `+${data.summary.estimatedGain} 台可达标` : null,
|
||||||
|
// Blue accent
|
||||||
|
idle: 'border-slate-200 bg-white',
|
||||||
|
active: 'border-blue-500 bg-blue-50 ring-1 ring-blue-500/20',
|
||||||
|
labelColor: 'text-slate-500',
|
||||||
|
labelActive: 'text-blue-600',
|
||||||
|
numColor: 'text-slate-800',
|
||||||
|
numActive: 'text-blue-700',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6" style={{ overflowX: 'clip' }}>
|
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6" style={{ overflowX: 'clip' }}>
|
||||||
@@ -48,11 +98,11 @@ export default function SchedulingModule() {
|
|||||||
{/* Batch Selector */}
|
{/* Batch Selector */}
|
||||||
<div className="bg-white p-2 rounded-2xl shadow-sm border border-slate-100 flex gap-1 overflow-x-auto no-scrollbar">
|
<div className="bg-white p-2 rounded-2xl shadow-sm border border-slate-100 flex gap-1 overflow-x-auto no-scrollbar">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedTargetId(undefined)}
|
onClick={() => { setSelectedTargetId(undefined); setTypeFilter('all'); }}
|
||||||
className={`flex-shrink-0 px-3 py-1.5 rounded-xl text-xs font-semibold transition-all ${
|
className={`flex-shrink-0 px-3 py-1.5 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
|
||||||
selectedTargetId === undefined
|
selectedTargetId === undefined
|
||||||
? 'bg-blue-600 text-white shadow-md shadow-blue-200'
|
? 'bg-slate-800 text-white shadow-md'
|
||||||
: 'bg-slate-50 text-slate-500'
|
: 'bg-slate-50 text-slate-500 hover:bg-slate-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
全部批次
|
全部批次
|
||||||
@@ -60,11 +110,11 @@ export default function SchedulingModule() {
|
|||||||
{data?.targets.map((target) => (
|
{data?.targets.map((target) => (
|
||||||
<button
|
<button
|
||||||
key={target.id}
|
key={target.id}
|
||||||
onClick={() => setSelectedTargetId(target.id)}
|
onClick={() => { setSelectedTargetId(target.id); setTypeFilter('all'); }}
|
||||||
className={`flex-shrink-0 px-3 py-1.5 rounded-xl text-xs font-semibold transition-all ${
|
className={`flex-shrink-0 px-3 py-1.5 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
|
||||||
selectedTargetId === target.id
|
selectedTargetId === target.id
|
||||||
? 'bg-blue-600 text-white shadow-md shadow-blue-200'
|
? 'bg-slate-800 text-white shadow-md'
|
||||||
: 'bg-slate-50 text-slate-500'
|
: 'bg-slate-50 text-slate-500 hover:bg-slate-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{shortTargetName(target.name)}
|
{shortTargetName(target.name)}
|
||||||
@@ -73,38 +123,46 @@ export default function SchedulingModule() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && !data ? (
|
{loading && !data ? (
|
||||||
/* Loading State */
|
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : data ? (
|
) : data ? (
|
||||||
<>
|
<>
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards — clickable filter */}
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<div className="bg-white border border-emerald-100 p-3 rounded-2xl">
|
{cardConfigs.map(cfg => {
|
||||||
<div className="text-[10px] font-bold text-emerald-600 mb-1">已达标</div>
|
const isActive = typeFilter === cfg.key;
|
||||||
<div className="text-xl font-black text-emerald-700">{data.summary.qualifiedCount}<span className="text-[10px] font-normal text-emerald-400 ml-1">台</span></div>
|
return (
|
||||||
</div>
|
<button
|
||||||
<div className="bg-white border border-rose-100 p-3 rounded-2xl">
|
key={cfg.key}
|
||||||
<div className="text-[10px] font-bold text-rose-500 mb-1">无望达标</div>
|
onClick={() => setTypeFilter(isActive ? 'all' : cfg.key)}
|
||||||
<div className="text-xl font-black text-rose-700">{data.summary.hopelessCount}<span className="text-[10px] font-normal text-rose-400 ml-1">台</span></div>
|
className={`p-3 rounded-2xl border text-left transition-all cursor-pointer ${isActive ? cfg.active : cfg.idle}`}
|
||||||
</div>
|
>
|
||||||
<div className="bg-white border border-blue-100 p-3 rounded-2xl">
|
<div className={`text-[10px] font-bold mb-1 transition-colors ${isActive ? cfg.labelActive : cfg.labelColor}`}>
|
||||||
<div className="text-[10px] font-bold text-blue-600 mb-1">可干预</div>
|
{cfg.label}
|
||||||
<div className="text-xl font-black text-blue-700">{data.summary.suggestionCount}<span className="text-[10px] font-normal text-blue-400 ml-1">条</span></div>
|
</div>
|
||||||
<div className="text-[9px] text-blue-400 mt-0.5">+{data.summary.estimatedGain} 台可达标</div>
|
<div className={`text-xl font-black transition-colors ${isActive ? cfg.numActive : cfg.numColor}`}>
|
||||||
</div>
|
{cfg.count}
|
||||||
|
<span className={`text-[10px] font-normal ml-1 ${isActive ? cfg.labelActive : 'text-slate-400'}`}>{cfg.unit}</span>
|
||||||
|
</div>
|
||||||
|
{cfg.sub && (
|
||||||
|
<div className={`text-[9px] mt-0.5 ${isActive ? cfg.labelActive : 'text-slate-400'}`}>{cfg.sub}</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Suggestion List */}
|
{/* Suggestion List */}
|
||||||
<SuggestionList
|
<SuggestionList
|
||||||
suggestions={data.suggestions}
|
suggestions={filteredSuggestions}
|
||||||
onSelect={setSelectedSuggestion}
|
onSelect={setSelectedSuggestion}
|
||||||
|
loading={loading}
|
||||||
|
onRefresh={loadData}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Detail Modal */}
|
|
||||||
{selectedSuggestion && (
|
{selectedSuggestion && (
|
||||||
<SuggestionDetail
|
<SuggestionDetail
|
||||||
suggestion={selectedSuggestion}
|
suggestion={selectedSuggestion}
|
||||||
@@ -112,7 +170,6 @@ export default function SchedulingModule() {
|
|||||||
onNotifySuccess={handleNotifySuccess}
|
onNotifySuccess={handleNotifySuccess}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ArrowRightLeft, ChevronRight } from 'lucide-react';
|
import { ArrowRightLeft, ChevronRight, RotateCcw } from 'lucide-react';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import type { SchedulingSuggestion } from './types';
|
import type { SchedulingSuggestion } from './types';
|
||||||
import Blur from '../../components/Blur';
|
import Blur from '../../components/Blur';
|
||||||
@@ -6,18 +6,15 @@ import Blur from '../../components/Blur';
|
|||||||
interface Props {
|
interface Props {
|
||||||
suggestions: SchedulingSuggestion[];
|
suggestions: SchedulingSuggestion[];
|
||||||
onSelect: (s: SchedulingSuggestion) => void;
|
onSelect: (s: SchedulingSuggestion) => void;
|
||||||
}
|
loading?: boolean;
|
||||||
|
onRefresh?: () => void;
|
||||||
function fmtKm(value: number): string {
|
|
||||||
if (value >= 10000) return (value / 10000).toFixed(1) + '万';
|
|
||||||
return value.toLocaleString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtRate(rate: number): string {
|
function fmtRate(rate: number): string {
|
||||||
return (rate * 100).toFixed(1) + '%';
|
return (rate * 100).toFixed(1) + '%';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SuggestionList({ suggestions, onSelect }: Props) {
|
export default function SuggestionList({ suggestions, onSelect, loading, onRefresh }: Props) {
|
||||||
if (suggestions.length === 0) {
|
if (suggestions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-12 text-center">
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-12 text-center">
|
||||||
@@ -32,6 +29,15 @@ export default function SuggestionList({ suggestions, onSelect }: Props) {
|
|||||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-slate-100">
|
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-slate-100">
|
||||||
<div className="w-1 h-4 rounded-full bg-blue-500" />
|
<div className="w-1 h-4 rounded-full bg-blue-500" />
|
||||||
<span className="text-sm font-bold text-slate-700 flex-1">调度干预清单</span>
|
<span className="text-sm font-bold text-slate-700 flex-1">调度干预清单</span>
|
||||||
|
{onRefresh && (
|
||||||
|
<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={13} className={loading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<span className="text-[10px] font-bold text-blue-600 bg-blue-50 px-2 py-0.5 rounded-full">
|
<span className="text-[10px] font-bold text-blue-600 bg-blue-50 px-2 py-0.5 rounded-full">
|
||||||
{suggestions.length}
|
{suggestions.length}
|
||||||
</span>
|
</span>
|
||||||
@@ -51,10 +57,10 @@ export default function SuggestionList({ suggestions, onSelect }: Props) {
|
|||||||
className="px-4 py-3 hover:bg-slate-50/60 cursor-pointer transition-colors active:bg-slate-100 flex items-center gap-3"
|
className="px-4 py-3 hover:bg-slate-50/60 cursor-pointer transition-colors active:bg-slate-100 flex items-center gap-3"
|
||||||
onClick={() => onSelect(s)}
|
onClick={() => onSelect(s)}
|
||||||
>
|
>
|
||||||
{/* Left: color bar */}
|
{/* Color bar */}
|
||||||
<div className={`w-1 h-10 rounded-full flex-shrink-0 ${isRescue ? 'bg-rose-400' : 'bg-emerald-400'}`} />
|
<div className={`w-1 h-10 rounded-full flex-shrink-0 ${isRescue ? 'bg-rose-400' : 'bg-emerald-400'}`} />
|
||||||
|
|
||||||
{/* Center: info */}
|
{/* Info */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs font-black text-slate-900 font-mono">
|
<span className="text-xs font-black text-slate-900 font-mono">
|
||||||
@@ -76,7 +82,7 @@ export default function SuggestionList({ suggestions, onSelect }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: candidate count + arrow */}
|
{/* Right */}
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
<span className="text-xs font-bold text-blue-600">{s.candidates.length}</span>
|
<span className="text-xs font-bold text-blue-600">{s.candidates.length}</span>
|
||||||
<span className="text-[9px] text-slate-400">辆</span>
|
<span className="text-[9px] text-slate-400">辆</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user