Polish mobile BI filters and summaries
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
lingniu
2026-06-27 22:43:43 +08:00
parent b0caa5afcb
commit a558db5795
10 changed files with 406 additions and 349 deletions

View File

@@ -14,21 +14,56 @@ const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
{ id: 'last15', label: '近 15 天' },
];
type RangeMode = DateQuickPick | 'custom';
function fmtYmd(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function addDays(d: Date, days: number): Date {
const next = new Date(d);
next.setDate(next.getDate() + days);
return next;
}
function getQuickRange(pick: DateQuickPick): { start: string; end: string } {
const today = new Date();
today.setHours(0, 0, 0, 0);
if (pick === 'thisWeek') {
const day = today.getDay() || 7;
return { start: fmtYmd(addDays(today, -(day - 1))), end: fmtYmd(today) };
}
if (pick === 'thisMonth') {
return { start: fmtYmd(new Date(today.getFullYear(), today.getMonth(), 1)), end: fmtYmd(today) };
}
return { start: fmtYmd(addDays(today, -14)), end: fmtYmd(today) };
}
function normalizeRange(start: string, end: string): { start: string; end: string } {
return start <= end ? { start, end } : { start: end, end: start };
}
export default function HydrogenDaily() {
const [pick, setPick] = useState<DateQuickPick>('last15');
const [pick, setPick] = useState<RangeMode>('last15');
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
const [customer, setCustomer] = useState<CustomerType>('lingniu');
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
const [error, setError] = useState<string | null>(null);
const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
useEffect(() => {
let cancelled = false;
setError(null);
fetchHydrogenDaily(pick, customer)
const query = pick === 'custom'
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
: { range: pick };
fetchHydrogenDaily(query, customer)
.then(r => { if (!cancelled) setRows(r); })
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; };
}, [pick, customer]);
}, [pick, customer, effectiveRange.start, effectiveRange.end]);
// 柱图:按日期升序,用于"从左到右时间流"
const trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]);
@@ -40,7 +75,10 @@ export default function HydrogenDaily() {
return names.size;
}, [rows]);
const avgKg = activeDays > 0 ? totalKg / activeDays : 0;
const scopeLabel = QUICK_PICK_OPTIONS.find(item => item.id === pick)?.label ?? '当前时段';
const scopeLabel = pick === 'custom'
? '自定义区间'
: QUICK_PICK_OPTIONS.find(item => item.id === pick)?.label ?? '当前时段';
const rangeText = `${effectiveRange.start}${effectiveRange.end}`;
const peakDay = trendData.reduce<HydrogenDailyRow | null>((best, item) => (!best || item.totalKg > best.totalKg ? item : best), null);
const lowDay = trendData
.filter(item => item.totalKg > 0)
@@ -53,47 +91,92 @@ export default function HydrogenDaily() {
return next;
});
const applyQuickPick = (nextPick: DateQuickPick) => {
setPick(nextPick);
setDateRange(getQuickRange(nextPick));
};
const updateDateRange = (field: 'start' | 'end', value: string) => {
if (!value) return;
setPick('custom');
setDateRange(prev => ({ ...prev, [field]: value }));
};
return (
<div className="flex flex-col gap-3">
<SurfaceCard className="p-2 md:p-3">
<div className="flex items-center gap-2 overflow-x-auto pb-1">
{QUICK_PICK_OPTIONS.map(opt => (
<button
key={opt.id}
onClick={() => applyQuickPick(opt.id)}
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === opt.id
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
}`}
>
{opt.label}
</button>
))}
<button
onClick={() => setPick('custom')}
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === 'custom'
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
}`}
>
</button>
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400"></span>
<input
type="date"
value={dateRange.start}
onChange={e => updateDateRange('start', e.target.value)}
onInput={e => updateDateRange('start', e.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label>
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400"></span>
<input
type="date"
value={dateRange.end}
onChange={e => updateDateRange('end', e.target.value)}
onInput={e => updateDateRange('end', e.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label>
</div>
<div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
{(['lingniu', 'external'] as const).map(c => (
<button
key={c}
onClick={() => setCustomer(c)}
className={`flex min-h-9 items-center justify-center gap-1.5 rounded-lg text-[12px] font-black transition-all ${
customer === c ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
<Truck size={14} />
{c === 'external' ? '外部车辆' : '羚牛车辆'}
</button>
))}
</div>
</SurfaceCard>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper="按日期汇总" />
<MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} />
<MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" tone="emerald" />
<MetricTile icon={TrendingUp} label="有效天数" value={`${activeDays}/${rows?.length ?? 0}`} helper={`日均 ${avgKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`} tone="amber" />
<MetricTile icon={Plug} label="涉及加氢站" value={stationCount} unit="站" helper="按明细站点去重" tone="slate" />
</div>
{/* 日期速选 */}
<div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x">
{QUICK_PICK_OPTIONS.map(opt => (
<button
key={opt.id}
onClick={() => setPick(opt.id)}
className={`shrink-0 snap-start rounded-xl px-3 py-1.5 text-[11px] font-bold border transition-colors ${
pick === opt.id
? 'bg-blue-50 text-blue-600 border-blue-200'
: 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50'
}`}
>
{opt.label}
</button>
))}
</div>
{/* 客户类型 segmented */}
<div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1">
{(['lingniu', 'external'] as const).map(c => (
<button
key={c}
onClick={() => setCustomer(c)}
className={`rounded-lg py-1.5 text-[12px] font-bold transition-all ${
customer === c ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500'
}`}
>
{c === 'external' ? '外部车辆' : '羚牛车辆'}
</button>
))}
</div>
{/* 外部车辆:新系统数据还没准备好 */}
{customer === 'external' && rows !== null && totalKg === 0 && (
<motion.div