feat: polish BI dashboards and bump version
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
lingniu
2026-06-27 21:59:33 +08:00
parent 5377d2c225
commit b0caa5afcb
33 changed files with 2363 additions and 483 deletions

View File

@@ -1,13 +1,210 @@
import { FileText } from 'lucide-react';
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);
}
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="flex items-center justify-center py-20">
<div className="text-center">
<FileText size={48} className="mx-auto text-gray-300 mb-4" />
<h2 className="text-lg font-semibold text-gray-500"></h2>
<p className="text-sm text-gray-400 mt-2">...</p>
<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>
</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>
<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>
);
}