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

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