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:
kkfluous
2026-04-16 21:12:02 +08:00
parent bcbeb64e28
commit 6f7555a407
2 changed files with 108 additions and 45 deletions

View File

@@ -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>
); );

View File

@@ -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>