feat: polish BI dashboards and bump version
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user