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 { Activity, AlertTriangle, CheckCircle, TrendingUp, RotateCcw } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
import { fetchSuggestions } from './api';
|
||||
import type { SchedulingResponse, SchedulingSuggestion } from './types';
|
||||
import SuggestionList from './SuggestionList';
|
||||
import SuggestionDetail from './SuggestionDetail';
|
||||
|
||||
type TypeFilter = 'all' | 'qualified' | 'hopeless';
|
||||
|
||||
function shortTargetName(name: string): string {
|
||||
const match = name.match(/(\d+)[辆台](.+)/);
|
||||
if (!match) return name;
|
||||
@@ -22,6 +23,7 @@ export default function SchedulingModule() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedTargetId, setSelectedTargetId] = useState<number | undefined>(undefined);
|
||||
const [selectedSuggestion, setSelectedSuggestion] = useState<SchedulingSuggestion | null>(null);
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -33,13 +35,61 @@ export default function SchedulingModule() {
|
||||
}
|
||||
}, [selectedTargetId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const handleNotifySuccess = useCallback(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
const handleNotifySuccess = useCallback(() => { 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 (
|
||||
<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 */}
|
||||
<div className="bg-white p-2 rounded-2xl shadow-sm border border-slate-100 flex gap-1 overflow-x-auto no-scrollbar">
|
||||
<button
|
||||
onClick={() => setSelectedTargetId(undefined)}
|
||||
className={`flex-shrink-0 px-3 py-1.5 rounded-xl text-xs font-semibold transition-all ${
|
||||
onClick={() => { setSelectedTargetId(undefined); setTypeFilter('all'); }}
|
||||
className={`flex-shrink-0 px-3 py-1.5 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
|
||||
selectedTargetId === undefined
|
||||
? 'bg-blue-600 text-white shadow-md shadow-blue-200'
|
||||
: 'bg-slate-50 text-slate-500'
|
||||
? 'bg-slate-800 text-white shadow-md'
|
||||
: 'bg-slate-50 text-slate-500 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
全部批次
|
||||
@@ -60,11 +110,11 @@ export default function SchedulingModule() {
|
||||
{data?.targets.map((target) => (
|
||||
<button
|
||||
key={target.id}
|
||||
onClick={() => setSelectedTargetId(target.id)}
|
||||
className={`flex-shrink-0 px-3 py-1.5 rounded-xl text-xs font-semibold transition-all ${
|
||||
onClick={() => { setSelectedTargetId(target.id); setTypeFilter('all'); }}
|
||||
className={`flex-shrink-0 px-3 py-1.5 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
|
||||
selectedTargetId === target.id
|
||||
? 'bg-blue-600 text-white shadow-md shadow-blue-200'
|
||||
: 'bg-slate-50 text-slate-500'
|
||||
? 'bg-slate-800 text-white shadow-md'
|
||||
: 'bg-slate-50 text-slate-500 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{shortTargetName(target.name)}
|
||||
@@ -73,38 +123,46 @@ export default function SchedulingModule() {
|
||||
</div>
|
||||
|
||||
{loading && !data ? (
|
||||
/* Loading State */
|
||||
<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>
|
||||
) : data ? (
|
||||
<>
|
||||
{/* Summary Cards */}
|
||||
{/* Summary Cards — clickable filter */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="bg-white border border-emerald-100 p-3 rounded-2xl">
|
||||
<div className="text-[10px] font-bold text-emerald-600 mb-1">已达标</div>
|
||||
<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>
|
||||
</div>
|
||||
<div className="bg-white border border-rose-100 p-3 rounded-2xl">
|
||||
<div className="text-[10px] font-bold text-rose-500 mb-1">无望达标</div>
|
||||
<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>
|
||||
</div>
|
||||
<div className="bg-white border border-blue-100 p-3 rounded-2xl">
|
||||
<div className="text-[10px] font-bold text-blue-600 mb-1">可干预</div>
|
||||
<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 className="text-[9px] text-blue-400 mt-0.5">+{data.summary.estimatedGain} 台可达标</div>
|
||||
</div>
|
||||
{cardConfigs.map(cfg => {
|
||||
const isActive = typeFilter === cfg.key;
|
||||
return (
|
||||
<button
|
||||
key={cfg.key}
|
||||
onClick={() => setTypeFilter(isActive ? 'all' : cfg.key)}
|
||||
className={`p-3 rounded-2xl border text-left transition-all cursor-pointer ${isActive ? cfg.active : cfg.idle}`}
|
||||
>
|
||||
<div className={`text-[10px] font-bold mb-1 transition-colors ${isActive ? cfg.labelActive : cfg.labelColor}`}>
|
||||
{cfg.label}
|
||||
</div>
|
||||
<div className={`text-xl font-black transition-colors ${isActive ? cfg.numActive : cfg.numColor}`}>
|
||||
{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>
|
||||
|
||||
{/* Suggestion List */}
|
||||
<SuggestionList
|
||||
suggestions={data.suggestions}
|
||||
suggestions={filteredSuggestions}
|
||||
onSelect={setSelectedSuggestion}
|
||||
loading={loading}
|
||||
onRefresh={loadData}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* Detail Modal */}
|
||||
{selectedSuggestion && (
|
||||
<SuggestionDetail
|
||||
suggestion={selectedSuggestion}
|
||||
@@ -112,7 +170,6 @@ export default function SchedulingModule() {
|
||||
onNotifySuccess={handleNotifySuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
</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 type { SchedulingSuggestion } from './types';
|
||||
import Blur from '../../components/Blur';
|
||||
@@ -6,18 +6,15 @@ import Blur from '../../components/Blur';
|
||||
interface Props {
|
||||
suggestions: SchedulingSuggestion[];
|
||||
onSelect: (s: SchedulingSuggestion) => void;
|
||||
}
|
||||
|
||||
function fmtKm(value: number): string {
|
||||
if (value >= 10000) return (value / 10000).toFixed(1) + '万';
|
||||
return value.toLocaleString();
|
||||
loading?: boolean;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
function fmtRate(rate: number): string {
|
||||
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) {
|
||||
return (
|
||||
<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="w-1 h-4 rounded-full bg-blue-500" />
|
||||
<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">
|
||||
{suggestions.length}
|
||||
</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"
|
||||
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'}`} />
|
||||
|
||||
{/* Center: info */}
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-black text-slate-900 font-mono">
|
||||
@@ -76,7 +82,7 @@ export default function SuggestionList({ suggestions, onSelect }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: candidate count + arrow */}
|
||||
{/* Right */}
|
||||
<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-[9px] text-slate-400">辆</span>
|
||||
|
||||
Reference in New Issue
Block a user