Polish mobile BI filters and summaries
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -168,19 +168,19 @@ export function MetricTile({
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-black text-slate-400">{label}</div>
|
||||
<div className="mt-2 flex items-end gap-1">
|
||||
<span className="truncate text-2xl font-black tracking-tight text-slate-950">{value}</span>
|
||||
{unit ? <span className="pb-1 text-[11px] font-black text-slate-400">{unit}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 text-[11px] font-black text-slate-400">{label}</div>
|
||||
{Icon ? (
|
||||
<span className={cn('inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ring-1', toneClass)}>
|
||||
<Icon size={18} />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 flex min-w-0 items-end gap-1">
|
||||
<span className="min-w-0 whitespace-nowrap text-[clamp(1.65rem,6vw,2rem)] font-black leading-none tracking-tight text-slate-950 tabular-nums">
|
||||
{value}
|
||||
</span>
|
||||
{unit ? <span className="shrink-0 pb-0.5 text-[11px] font-black text-slate-400">{unit}</span> : null}
|
||||
</div>
|
||||
{helper ? <div className="mt-3 text-[11px] font-bold leading-relaxed text-slate-500">{helper}</div> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -65,3 +65,8 @@ body {
|
||||
radial-gradient(circle at 90% 12%, rgba(20, 184, 166, 0.08), transparent 26%),
|
||||
linear-gradient(180deg, #f8fbff 0%, var(--app-bg) 42%, #f7f9fc 100%);
|
||||
}
|
||||
|
||||
.asset-date-input::-webkit-calendar-picker-indicator {
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -950,7 +950,6 @@ export default function AssetsModule() {
|
||||
<span>交还车统计</span>
|
||||
{flowLoading && <Loader2 size={12} className="animate-spin text-slate-400" />}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[10px] font-bold text-slate-400">默认上周六-本周五 · 提交时间口径</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
@@ -964,34 +963,37 @@ export default function AssetsModule() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
<label className="relative cursor-pointer rounded-xl border border-slate-100 bg-slate-50 px-2.5 py-1.5 transition hover:border-blue-100 hover:bg-blue-50/50">
|
||||
<div className="mt-2 rounded-xl border border-slate-100 bg-slate-50/80 px-2 py-1.5">
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
|
||||
<label className="relative cursor-pointer rounded-lg px-2 py-1 transition hover:bg-white">
|
||||
<span className="block text-[9px] font-black text-slate-400">开始</span>
|
||||
<span className="mt-0.5 flex items-center justify-between gap-2">
|
||||
<span className="text-[12px] font-black text-slate-700">{flowRange.start.replaceAll('-', '/')}</span>
|
||||
<CalendarDays size={13} className="text-slate-400" />
|
||||
<CalendarDays size={12} className="text-slate-300" />
|
||||
</span>
|
||||
<input
|
||||
type="date"
|
||||
value={flowRange.start}
|
||||
onChange={(e) => setFlowRange((prev) => ({ ...prev, start: e.target.value }))}
|
||||
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||
className="asset-date-input absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||
/>
|
||||
</label>
|
||||
<label className="relative cursor-pointer rounded-xl border border-slate-100 bg-slate-50 px-2.5 py-1.5 transition hover:border-blue-100 hover:bg-blue-50/50">
|
||||
<div className="rounded-full bg-slate-200/70 px-2 py-0.5 text-[9px] font-black text-slate-400">至</div>
|
||||
<label className="relative cursor-pointer rounded-lg px-2 py-1 transition hover:bg-white">
|
||||
<span className="block text-[9px] font-black text-slate-400">结束</span>
|
||||
<span className="mt-0.5 flex items-center justify-between gap-2">
|
||||
<span className="text-[12px] font-black text-slate-700">{flowRange.end.replaceAll('-', '/')}</span>
|
||||
<CalendarDays size={13} className="text-slate-400" />
|
||||
<CalendarDays size={12} className="text-slate-300" />
|
||||
</span>
|
||||
<input
|
||||
type="date"
|
||||
value={flowRange.end}
|
||||
onChange={(e) => setFlowRange((prev) => ({ ...prev, end: e.target.value }))}
|
||||
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||
className="asset-date-input absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 rounded-2xl border border-slate-100 bg-slate-50/70 px-2 py-2.5">
|
||||
<div className="grid grid-cols-4 items-center text-center">
|
||||
<div className="px-2">
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { BatteryCharging, CalendarDays, ChevronRight, Plug, TrendingUp, Wallet } from 'lucide-react';
|
||||
import { BatteryCharging, CalendarDays, ChevronRight, Plug, TrendingUp, Truck, Wallet } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import TrendBadge from './TrendBadge';
|
||||
import { fetchElectricMonthly } from './api';
|
||||
import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { EmptyState, ErrorState, LoadingState, MetricTile } from '../../components/ui/surface';
|
||||
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||
|
||||
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
||||
{ id: 'thisWeek', label: '本周' },
|
||||
@@ -13,26 +13,61 @@ 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 ElectricDaily() {
|
||||
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
||||
const [pick, setPick] = useState<DateQuickPick>('last15');
|
||||
const [pick, setPick] = useState<RangeMode>('last15');
|
||||
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
|
||||
const [months, setMonths] = useState<ElectricMonthGroup[] | null>(null);
|
||||
const [openMonths, setOpenMonths] = useState<Set<string>>(new Set());
|
||||
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);
|
||||
fetchElectricMonthly(customer, pick)
|
||||
const query = pick === 'custom'
|
||||
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
|
||||
: { range: pick };
|
||||
fetchElectricMonthly(customer, query)
|
||||
.then(m => {
|
||||
if (cancelled) return;
|
||||
setMonths(m);
|
||||
// 默认展开最新一个月
|
||||
if (m.length > 0) setOpenMonths(prev => prev.size > 0 ? prev : new Set([m[0].month]));
|
||||
if (m.length > 0) setOpenMonths(new Set([m[0].month]));
|
||||
})
|
||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [customer, pick]);
|
||||
}, [customer, pick, effectiveRange.start, effectiveRange.end]);
|
||||
|
||||
const toggleMonth = (m: string) => setOpenMonths(prev => {
|
||||
const next = new Set(prev);
|
||||
@@ -46,14 +81,94 @@ export default function ElectricDaily() {
|
||||
const abnormalDays = useMemo(() => (months ?? []).reduce((sum, m) => sum + m.rows.filter(r => Math.abs(r.chainPct) >= 0.3).length, 0), [months]);
|
||||
const avgKwh = activeDays > 0 ? totalKwh / activeDays : 0;
|
||||
const avgPrice = totalKwh > 0 ? totalFee / totalKwh : 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 hasFeeDetail = totalFee > 0;
|
||||
const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0;
|
||||
|
||||
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={BatteryCharging} label={`${scopeLabel}充电量`} value={totalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="度" helper="按日期汇总" />
|
||||
<MetricTile icon={BatteryCharging} label={`${scopeLabel}充电量`} value={totalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="度" helper={rangeText} />
|
||||
<MetricTile
|
||||
icon={Wallet}
|
||||
label="充电费用"
|
||||
@@ -65,38 +180,6 @@ export default function ElectricDaily() {
|
||||
<MetricTile icon={TrendingUp} label="波动提醒" value={abnormalDays} unit="天" helper="环比超过 30% 标记" tone={abnormalDays > 0 ? 'rose' : '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>
|
||||
|
||||
{/* 客户类型 */}
|
||||
<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>
|
||||
|
||||
{/* 外部车辆 数据未就绪 */}
|
||||
{showExternalEmpty && (
|
||||
<motion.div
|
||||
|
||||
@@ -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,46 +91,91 @@ 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">
|
||||
<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={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">
|
||||
<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={() => setPick(opt.id)}
|
||||
className={`shrink-0 snap-start rounded-xl px-3 py-1.5 text-[11px] font-bold border transition-colors ${
|
||||
onClick={() => applyQuickPick(opt.id)}
|
||||
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black 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'
|
||||
? '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>
|
||||
|
||||
{/* 客户类型 segmented */}
|
||||
<div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1">
|
||||
<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={`rounded-lg py-1.5 text-[12px] font-bold transition-all ${
|
||||
customer === c ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500'
|
||||
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={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>
|
||||
|
||||
{/* 外部车辆:新系统数据还没准备好 */}
|
||||
{customer === 'external' && rows !== null && totalKg === 0 && (
|
||||
|
||||
@@ -166,7 +166,7 @@ export default function HydrogenOverview() {
|
||||
<div className="flex flex-col gap-3 relative">
|
||||
{/* 顶部说明条 + 年份切换 + 刷新按钮 */}
|
||||
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400 flex items-center justify-between gap-2">
|
||||
<span className="truncate">{lastRefreshAt ? `更新于 ${formatRelative(lastRefreshAt)}` : '数据自 2025-01-01 起'}</span>
|
||||
<span className="truncate">{lastRefreshAt ? `更新于 ${formatRefreshTime(lastRefreshAt)}` : '数据自 2025-01-01 起'}</span>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-1 bg-slate-50 rounded-lg p-0.5">
|
||||
{availableYears.map(y => {
|
||||
@@ -620,6 +620,18 @@ function formatRelative(ts: number): string {
|
||||
return new Date(ts).toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function formatRefreshTime(ts: number): string {
|
||||
const exactTime = new Date(ts).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
return `${formatRelative(ts)} · ${exactTime.replace(/\//g, '-')}`;
|
||||
}
|
||||
|
||||
function HydrogenOverviewSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 animate-pulse">
|
||||
|
||||
@@ -27,8 +27,17 @@ export function fetchHydrogenOverview(year?: number, force = false): Promise<Hyd
|
||||
return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview${q ? `?${q}` : ''}`);
|
||||
}
|
||||
|
||||
export function fetchHydrogenDaily(range: DateQuickPick, customer: CustomerType): Promise<HydrogenDailyRow[]> {
|
||||
const q = new URLSearchParams({ range, customer });
|
||||
export interface HydrogenDailyQuery {
|
||||
range?: DateQuickPick;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: CustomerType): Promise<HydrogenDailyRow[]> {
|
||||
const q = new URLSearchParams({ customer });
|
||||
if (query.range) q.set('range', query.range);
|
||||
if (query.startDate) q.set('startDate', query.startDate);
|
||||
if (query.endDate) q.set('endDate', query.endDate);
|
||||
return fetchJson<HydrogenDailyRow[]>(`${BASE}/hydrogen/daily?${q.toString()}`);
|
||||
}
|
||||
|
||||
@@ -41,7 +50,10 @@ export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
|
||||
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
|
||||
}
|
||||
|
||||
export function fetchElectricMonthly(customer: CustomerType, range: DateQuickPick = 'last15'): Promise<ElectricMonthGroup[]> {
|
||||
const q = new URLSearchParams({ customer, range });
|
||||
export function fetchElectricMonthly(customer: CustomerType, query: HydrogenDailyQuery = { range: 'last15' }): Promise<ElectricMonthGroup[]> {
|
||||
const q = new URLSearchParams({ customer });
|
||||
if (query.range) q.set('range', query.range);
|
||||
if (query.startDate) q.set('startDate', query.startDate);
|
||||
if (query.endDate) q.set('endDate', query.endDate);
|
||||
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`);
|
||||
}
|
||||
|
||||
@@ -1,209 +1,17 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { AlertTriangle, ArrowDownRight, BarChart3, CheckCircle2, Database, Route, Target, Truck } from 'lucide-react';
|
||||
import { ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||
import { fetchDailyReport, type DailyReportData, type DailyReportVehicle } from './api';
|
||||
|
||||
function fmt(value: number, digits = 0) {
|
||||
return value.toLocaleString('zh-CN', { maximumFractionDigits: digits });
|
||||
}
|
||||
|
||||
function fmtKm(value: number, digits = 1) {
|
||||
if (Math.abs(value) >= 10000) return `${(value / 10000).toFixed(digits)}万`;
|
||||
return fmt(value, digits);
|
||||
}
|
||||
import { FileText } from 'lucide-react';
|
||||
import { SurfaceCard } from '../../components/ui/surface';
|
||||
|
||||
export default function DailyReportView() {
|
||||
const [data, setData] = useState<DailyReportData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetchDailyReport()
|
||||
.then(result => { if (!cancelled) setData(result); })
|
||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const models = data?.models ?? [];
|
||||
return models.reduce(
|
||||
(acc, item) => ({
|
||||
count: acc.count + item.count,
|
||||
today: acc.today + item.today,
|
||||
total: acc.total + item.total,
|
||||
active: acc.active + item.active,
|
||||
zero: acc.zero + item.zero,
|
||||
dailyNeed: acc.dailyNeed + item.dailyNeed,
|
||||
}),
|
||||
{ count: 0, today: 0, total: 0, active: 0, zero: 0, dailyNeed: 0 },
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
if (loading) return <LoadingState label="正在从数据库生成每日汇报" />;
|
||||
if (error) return <ErrorState message={error} />;
|
||||
if (!data) return <ErrorState message="日报数据为空" />;
|
||||
|
||||
const activeRate = totals.count > 0 ? (totals.active / totals.count) * 100 : 0;
|
||||
const dailyGap = totals.today - totals.dailyNeed;
|
||||
const maxTrend = Math.max(1, ...data.trend.map(item => item.value));
|
||||
const maxModel = Math.max(1, ...data.models.map(item => item.today));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SurfaceCard className="overflow-hidden">
|
||||
<div className="grid gap-3 p-4 md:grid-cols-[1.3fr_1fr] md:items-center">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="text-[11px] font-black text-blue-600">数据截止 {data.reportDate} 23:59 · 数据库口径</div>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] font-black text-emerald-600">
|
||||
<Database size={11} />
|
||||
DB LIVE
|
||||
</span>
|
||||
<SurfaceCard className="min-h-[360px]">
|
||||
<div className="flex min-h-[320px] flex-col items-center justify-center px-6 py-10 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
|
||||
<FileText size={26} />
|
||||
</div>
|
||||
<h2 className="mt-2 text-xl font-black tracking-tight text-slate-950">车辆里程每日汇报</h2>
|
||||
<p className="mt-2 text-xs font-bold leading-relaxed text-slate-500">
|
||||
基于里程考核目标、车辆日里程视图和车辆归属信息实时聚合,和统计报表/实时监控保持同一数据库口径。
|
||||
</p>
|
||||
<div className="mt-4 text-base font-black text-slate-800">每日汇报接入中</div>
|
||||
<div className="mt-2 max-w-md text-xs font-bold leading-relaxed text-slate-400">
|
||||
日报数据口径正在整理,完成后将接入数据库统计与导出能力。
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 rounded-2xl bg-slate-50 p-2 text-center">
|
||||
<div className="rounded-xl bg-white px-2 py-2">
|
||||
<div className="text-lg font-black text-slate-950">{fmt(totals.count)}</div>
|
||||
<div className="text-[10px] font-black text-slate-400">车辆 台</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white px-2 py-2">
|
||||
<div className="text-lg font-black text-emerald-600">{activeRate.toFixed(1)}%</div>
|
||||
<div className="text-[10px] font-black text-slate-400">有里程</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white px-2 py-2">
|
||||
<div className="text-lg font-black text-rose-600">{totals.zero}</div>
|
||||
<div className="text-[10px] font-black text-slate-400">零里程</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricTile icon={Route} label="当日总里程" value={fmtKm(totals.today)} unit="km" helper={`${data.reportDate} 数据库合计`} />
|
||||
<MetricTile icon={Truck} label="当日有里程车辆" value={`${totals.active}/${totals.count}`} helper={`零里程 ${totals.zero} 台`} tone="emerald" />
|
||||
<MetricTile icon={Target} label="日需完成" value={fmtKm(totals.dailyNeed)} unit="km" helper="当前考核年度压力折算" tone="amber" />
|
||||
<MetricTile
|
||||
icon={ArrowDownRight}
|
||||
label="对比日需"
|
||||
value={`${dailyGap >= 0 ? '+' : '-'}${fmtKm(Math.abs(dailyGap))}`}
|
||||
unit="km"
|
||||
helper={dailyGap >= 0 ? '今日高于日需目标' : '今日低于日需目标'}
|
||||
tone={dailyGap >= 0 ? 'emerald' : 'rose'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[1.15fr_1fr]">
|
||||
<SurfaceCard title="近 7 日总里程趋势" subtitle="时间单位:日 · 单位 km · 数据来自 v_vehicle_daily_stats">
|
||||
<div className="flex h-64 items-end gap-2 px-4 pb-4 pt-6">
|
||||
{data.trend.map(item => (
|
||||
<div key={item.date} className="flex min-w-0 flex-1 flex-col items-center gap-2">
|
||||
<div className="relative flex h-44 w-full items-end rounded-xl bg-slate-50">
|
||||
<div
|
||||
className="w-full rounded-xl bg-gradient-to-t from-blue-600 to-cyan-400 shadow-sm"
|
||||
style={{ height: `${Math.max(4, (item.value / maxTrend) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[10px] font-black text-slate-400">{item.date}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
|
||||
<SurfaceCard title="车型任务进度" subtitle={`时间单位:${data.reportDate} 单日 / 当前考核年度`}>
|
||||
<div className="space-y-3 p-4">
|
||||
{data.models.map(item => (
|
||||
<div key={item.id}>
|
||||
<div className="mb-1.5 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs font-black text-slate-800">{item.name}</div>
|
||||
<div className="mt-0.5 text-[10px] font-bold text-slate-400">{item.active}/{item.count} 有里程 · 零里程 {item.zero}</div>
|
||||
</div>
|
||||
<div className="text-right text-xs font-black text-blue-600">{fmt(item.today, 1)} km</div>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-slate-100">
|
||||
<div className="h-full rounded-full bg-blue-500" style={{ width: `${Math.max(4, (item.today / maxModel) * 100)}%` }} />
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between text-[10px] font-bold text-slate-400">
|
||||
<span>年度完成 {item.completion.toFixed(1)}%</span>
|
||||
<span>日需 {fmt(item.dailyNeed, 1)} km</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<ReportList title="当日里程 Top 5" subtitle={`时间单位:${data.reportDate} 单日 · 单位 km`} rows={data.topVehicles} mode="top" />
|
||||
<ReportList title="需要关注:运营中零里程" subtitle="筛选租赁/自营车辆,按累计完成率优先跟进" rows={data.zeroRisk} mode="risk" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<SurfaceCard>
|
||||
<div className="p-4">
|
||||
<CheckCircle2 className="mb-2 text-emerald-500" size={18} />
|
||||
<div className="text-sm font-black text-slate-900">{data.qualifiedCount} 台已达当前年度标准</div>
|
||||
<div className="mt-1 text-[11px] font-bold text-slate-400">{data.halfQualifiedCount} 台达到 50% 里程线</div>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
<SurfaceCard>
|
||||
<div className="p-4">
|
||||
<BarChart3 className="mb-2 text-blue-500" size={18} />
|
||||
<div className="text-sm font-black text-slate-900">全量均值 {fmt(totals.count > 0 ? totals.today / totals.count : 0, 1)} km/台</div>
|
||||
<div className="mt-1 text-[11px] font-bold text-slate-400">有里程车辆均值 {fmt(totals.active > 0 ? totals.today / totals.active : 0, 1)} km/台</div>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
<SurfaceCard>
|
||||
<div className="p-4">
|
||||
<AlertTriangle className="mb-2 text-amber-500" size={18} />
|
||||
<div className="text-sm font-black text-slate-900">缺口 {fmtKm(Math.abs(dailyGap))} km</div>
|
||||
<div className="mt-1 text-[11px] font-bold text-slate-400">建议关注零里程及低完成率车辆</div>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportList({
|
||||
title,
|
||||
subtitle,
|
||||
rows,
|
||||
mode,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
rows: DailyReportVehicle[];
|
||||
mode: 'top' | 'risk';
|
||||
}) {
|
||||
return (
|
||||
<SurfaceCard title={title} subtitle={subtitle}>
|
||||
<div className="divide-y divide-slate-50">
|
||||
{rows.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-xs font-bold text-slate-400">暂无匹配车辆</div>
|
||||
) : rows.map(item => (
|
||||
<div key={`${item.plate}-${mode}`} className="grid grid-cols-[1fr_auto] gap-3 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-black text-slate-900">{item.plate}</span>
|
||||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-black text-slate-500">{item.model}</span>
|
||||
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-[10px] font-black text-blue-600">{item.status}</span>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-[11px] font-bold text-slate-400">{item.customer}</div>
|
||||
</div>
|
||||
<div className={mode === 'top' ? 'text-right text-sm font-black text-blue-600' : 'text-right text-sm font-black text-amber-600'}>
|
||||
{mode === 'top' ? `${fmt(item.today ?? 0, 1)} km` : `${(item.completion ?? 0).toFixed(1)}%`}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Maximize2, Minimize2, RotateCcw,
|
||||
ArrowUp, ArrowDown, ChevronsUp, Download, Check, CalendarDays,
|
||||
} from 'lucide-react';
|
||||
import { BarChart, Bar, ResponsiveContainer, Tooltip, ReferenceLine } from 'recharts';
|
||||
import { BarChart, Bar, ResponsiveContainer, Tooltip, ReferenceLine, XAxis } from 'recharts';
|
||||
import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types';
|
||||
import { fetchMonitoring } from './api';
|
||||
import Blur from '../../components/Blur';
|
||||
@@ -1140,7 +1140,6 @@ export default function MonitoringView() {
|
||||
区间走势
|
||||
</div>
|
||||
<div className="mt-1 text-[9px] font-bold text-slate-400">{rangeDailyTotals.length} 天 · km</div>
|
||||
<div className="mt-0.5 truncate text-[9px] font-bold text-slate-400">{rangeLabel}</div>
|
||||
</div>
|
||||
<div className="h-[58px] min-w-0">
|
||||
{rangeDailyTotals.length === 0 ? (
|
||||
@@ -1148,9 +1147,10 @@ export default function MonitoringView() {
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={58} minWidth={0}>
|
||||
<BarChart data={rangeDailyTotals} margin={{ top: 4, right: 2, bottom: 0, left: 2 }}>
|
||||
<XAxis dataKey="date" hide />
|
||||
<Tooltip
|
||||
formatter={(value) => [`${Number(value ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} km`, '当日里程']}
|
||||
labelFormatter={(label) => `日期 ${label}`}
|
||||
labelFormatter={(label) => `日期 ${String(label)}`}
|
||||
contentStyle={{ borderRadius: 10, borderColor: '#e2e8f0', fontSize: 11 }}
|
||||
cursor={{ fill: 'rgba(37, 99, 235, 0.06)' }}
|
||||
/>
|
||||
@@ -1167,15 +1167,25 @@ export default function MonitoringView() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_84px_84px] items-center gap-2 px-3 py-2">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_76px_76px] items-center gap-2 px-3 py-2 md:grid-cols-[minmax(0,1fr)_96px_96px]">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1 text-[10px] font-black text-slate-700">
|
||||
<CalendarDays size={12} className="text-blue-500" />
|
||||
单日概览
|
||||
</div>
|
||||
<div className="mt-1 truncate text-[9px] font-bold text-slate-400">{rangeLabel} · 单位 km</div>
|
||||
<div className="mt-1 truncate text-[10px] font-bold text-slate-500">
|
||||
当前列表最高 {topLoadedVehicle ? `${topLoadedVehicle.plate} · ${Math.round(topLoadedVehicle.dailyKm).toLocaleString()} km` : '-'}
|
||||
<div className="mt-1.5 rounded-lg bg-slate-50 px-2 py-1 ring-1 ring-slate-100">
|
||||
<div className="text-[8px] font-black text-slate-400">当前列表最高</div>
|
||||
{topLoadedVehicle ? (
|
||||
<div className="mt-0.5 flex flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5">
|
||||
<span className="whitespace-nowrap text-[10px] font-black text-slate-700">{topLoadedVehicle.plate}</span>
|
||||
<span className="whitespace-nowrap text-[10px] font-black text-blue-600 tabular-nums">
|
||||
{topLoadedVehicle.dailyKm.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} km
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-0.5 text-[10px] font-black text-slate-300">-</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-blue-50 px-2 py-1.5 text-right">
|
||||
|
||||
@@ -37,6 +37,53 @@ function customerClause(customer: CustomerKind): string {
|
||||
}
|
||||
|
||||
type Range = 'thisWeek' | 'thisMonth' | 'last15';
|
||||
interface DateRange {
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
const YMD_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
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 parseYmd(value: string | undefined): string | null {
|
||||
if (!value || !YMD_RE.test(value)) return null;
|
||||
const d = new Date(`${value}T00:00:00`);
|
||||
return Number.isNaN(d.getTime()) ? null : value;
|
||||
}
|
||||
|
||||
function resolveDateRange(range: Range, startParam?: string, endParam?: string): DateRange {
|
||||
const customStart = parseYmd(startParam);
|
||||
const customEnd = parseYmd(endParam);
|
||||
if (customStart && customEnd) {
|
||||
return customStart <= customEnd
|
||||
? { start: customStart, end: customEnd }
|
||||
: { start: customEnd, end: customStart };
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (range === 'thisWeek') {
|
||||
const day = today.getDay() || 7;
|
||||
return { start: fmtYmd(addDays(today, -(day - 1))), end: fmtYmd(today) };
|
||||
}
|
||||
if (range === 'thisMonth') {
|
||||
return { start: fmtYmd(new Date(today.getFullYear(), today.getMonth(), 1)), end: fmtYmd(today) };
|
||||
}
|
||||
return { start: fmtYmd(addDays(today, -14)), end: fmtYmd(today) };
|
||||
}
|
||||
|
||||
function dateRangeClause(localExpr: string): string {
|
||||
return `${localExpr} >= ? AND ${localExpr} < DATE_ADD(?, INTERVAL 1 DAY)`;
|
||||
}
|
||||
|
||||
function rangeClause(localExpr: string, range: Range): string {
|
||||
switch (range) {
|
||||
@@ -48,25 +95,16 @@ function rangeClause(localExpr: string, range: Range): string {
|
||||
|
||||
/** 列出某 range 在当前时点下的全部日期(YYYY-MM-DD),用于补零 */
|
||||
function enumerateDates(range: Range): string[] {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const fmt = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
let start: Date;
|
||||
if (range === 'thisWeek') {
|
||||
// 周一为一周开始(与 YEARWEEK(?, 1) 一致)
|
||||
const day = today.getDay() || 7; // 周日 7
|
||||
start = new Date(today);
|
||||
start.setDate(today.getDate() - (day - 1));
|
||||
} else if (range === 'thisMonth') {
|
||||
start = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
} else {
|
||||
start = new Date(today);
|
||||
start.setDate(today.getDate() - 14);
|
||||
}
|
||||
const { start, end } = resolveDateRange(range);
|
||||
return enumerateDateRange(start, end);
|
||||
}
|
||||
|
||||
function enumerateDateRange(startYmd: string, endYmd: string): string[] {
|
||||
const result: string[] = [];
|
||||
const cur = new Date(start);
|
||||
while (cur <= today) {
|
||||
result.push(fmt(cur));
|
||||
const cur = new Date(`${startYmd}T00:00:00`);
|
||||
const end = new Date(`${endYmd}T00:00:00`);
|
||||
while (cur <= end) {
|
||||
result.push(fmtYmd(cur));
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
return result;
|
||||
@@ -331,15 +369,16 @@ app.get('/hydrogen/overview', async (c) => {
|
||||
// =========================================================
|
||||
app.get('/hydrogen/daily', async (c) => {
|
||||
const range = (c.req.query('range') || 'last15') as Range;
|
||||
const dateRange = resolveDateRange(range, c.req.query('startDate'), c.req.query('endDate'));
|
||||
const customer = (c.req.query('customer') || 'external') as CustomerKind;
|
||||
const force = c.req.query('force') === '1';
|
||||
|
||||
const data = await cached(`hydrogen/daily?range=${range}&customer=${customer}`, async () => {
|
||||
const data = await cached(`hydrogen/daily?start=${dateRange.start}&end=${dateRange.end}&customer=${customer}`, async () => {
|
||||
|
||||
const where = [
|
||||
HYDROGEN_BASE_WHERE_B,
|
||||
`b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`,
|
||||
rangeClause(`b.${HYDROGEN_LOCAL}`, range),
|
||||
dateRangeClause(`b.${HYDROGEN_LOCAL}`),
|
||||
customerClause(customer).replaceAll('customer_price', 'b.customer_price').replaceAll('fee_total', 'b.fee_total'),
|
||||
].join(' AND ');
|
||||
|
||||
@@ -360,6 +399,7 @@ app.get('/hydrogen/daily', async (c) => {
|
||||
WHERE ${where}
|
||||
GROUP BY d, COALESCE(b.station_id, 0)
|
||||
ORDER BY d DESC, kg DESC`,
|
||||
[dateRange.start, dateRange.end],
|
||||
);
|
||||
|
||||
// 站点环比:同站点上一条记录的 kg
|
||||
@@ -409,7 +449,7 @@ app.get('/hydrogen/daily', async (c) => {
|
||||
}
|
||||
|
||||
// 补零:列出 range 内全部日期,缺失日期返回 totalKg=0、stations=[]
|
||||
const allDates = enumerateDates(range);
|
||||
const allDates = enumerateDateRange(dateRange.start, dateRange.end);
|
||||
const fullDays = allDates.map(date => {
|
||||
const info = dayMap.get(date);
|
||||
return {
|
||||
@@ -533,9 +573,10 @@ app.get('/electric/overview', async (c) => {
|
||||
app.get('/electric/monthly', async (c) => {
|
||||
const customer = (c.req.query('customer') || 'lingniu') as CustomerKind;
|
||||
const range = (c.req.query('range') || 'last15') as Range;
|
||||
const dateRange = resolveDateRange(range, c.req.query('startDate'), c.req.query('endDate'));
|
||||
const force = c.req.query('force') === '1';
|
||||
|
||||
const data = await cached(`electric/monthly?customer=${customer}&range=${range}`, async () => {
|
||||
const data = await cached(`electric/monthly?customer=${customer}&start=${dateRange.start}&end=${dateRange.end}`, async () => {
|
||||
|
||||
// bi_ele_charge_record 用 vehicle_kind 区分:internal=我司,external=外部
|
||||
let kindClause = '1=1';
|
||||
@@ -548,8 +589,9 @@ app.get('/electric/monthly', async (c) => {
|
||||
SUM(fee) AS fee
|
||||
FROM bi_ele_charge_record
|
||||
WHERE ${kindClause}
|
||||
AND ${rangeClause('start_time', range)}
|
||||
AND ${dateRangeClause('start_time')}
|
||||
GROUP BY date`,
|
||||
[dateRange.start, dateRange.end],
|
||||
);
|
||||
|
||||
// 实际数据 map
|
||||
@@ -562,7 +604,7 @@ app.get('/electric/monthly', async (c) => {
|
||||
}
|
||||
|
||||
// 补零:枚举 range 全部日期
|
||||
const allDates = enumerateDates(range);
|
||||
const allDates = enumerateDateRange(dateRange.start, dateRange.end);
|
||||
const fullDays = allDates.map(date => {
|
||||
const d = dataMap.get(date);
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user