feat(energy): rebuild hydrogen BI board and drill-through
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
kkfluous
2026-08-20 13:59:03 +08:00
parent 37a9f303ec
commit 62efef0ab9
72 changed files with 36773 additions and 965 deletions
@@ -1,43 +1,76 @@
import type { ReactNode } from 'react';
import { CalendarDays, Fuel, Sparkles, TrendingUp, Wallet } from 'lucide-react';
import { Activity, Fuel, Search, Truck, Wallet, Zap } from 'lucide-react';
import { useState, type ReactNode } from 'react';
import type { HydrogenKpi } from '../../types';
import { formatKg as fmtKg, formatYuan as fmtYuan } from '../model';
import {
buildMetricDrillPayload,
formatKg as fmtKg,
formatYuan as fmtYuan,
type OverviewDrillPayload,
type OverviewDrillRequest,
type OverviewMetricKey,
type OverviewScope,
} from '../model';
import { OverviewDrillDialog } from './OverviewDrillDialog';
interface KpiCardProps {
icon: ReactNode;
metricKey: OverviewMetricKey;
label: string;
hero: { value: string; unit: string };
rows: { label: string; value: string; valueClass?: string }[];
accentClass: string;
iconBg: string;
rows: { label: string; value: string }[];
tone: 'blue' | 'green' | 'amber' | 'purple';
valueClass?: string;
onOpen: (key: OverviewMetricKey) => void;
}
function KpiCard({ icon, label, hero, rows, accentClass, iconBg }: KpiCardProps) {
const TONE_CLASS = {
blue: 'bg-blue-50 text-blue-600',
green: 'bg-emerald-50 text-emerald-600',
amber: 'bg-amber-50 text-amber-600',
purple: 'bg-violet-50 text-violet-600',
} as const;
function KpiCard({ icon, metricKey, label, hero, rows, tone, onOpen }: KpiCardProps) {
const isYuan = hero.value.startsWith('¥');
const numValue = isYuan ? hero.value.replace('¥', '') : hero.value;
return (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
<div className="flex items-center gap-2">
<div className={`w-7 h-7 rounded-xl flex items-center justify-center ${iconBg}`}>
{icon}
</div>
<span className="text-[11px] font-bold text-slate-500">{label}</span>
<div
className={`ehb-kpi-dual is-${tone}`}
onClick={() => onOpen(metricKey)}
title="点击查看真实汇总及可用下钻明细"
>
<div className="ehb-kpi-dual__head">
<span className="ehb-kpi-dual__label">
{label}
<span className="ehb-kpi-drill-hint">
<Search size={10} /> 钻取
</span>
</span>
<span className={`ehb-kpi-dual__badge is-${tone}`}>{icon}</span>
</div>
<div className="flex items-baseline gap-1">
<span className={`text-xl md:text-2xl font-black tabular-nums leading-none ${accentClass}`}>{hero.value}</span>
<span className="text-[11px] text-slate-400 font-bold">{hero.unit}</span>
<div className="ehb-kpi-dual__val">
{isYuan && <span className="ehb-kpi-dual__symbol">¥</span>}
<span className="ehb-kpi-dual__num">{numValue}</span>
{hero.unit && <span className="ehb-kpi-dual__unit">{hero.unit}</span>}
</div>
<div className="space-y-0.5 pt-1 border-t border-slate-50">
{rows.map((r, i) => (
<div key={i} className="flex items-center justify-between text-[11px] font-bold">
<span className="text-slate-400">{r.label}</span>
<span className={`tabular-nums ${r.valueClass ?? 'text-slate-700'}`}>{r.value}</span>
</div>
<div className="ehb-kpi-dual__deck">
{rows.map((row) => (
<span key={row.label}>{row.label} {row.value}</span>
))}
</div>
</div>
);
}
export function KpiSection({ kpi: k }: { kpi: HydrogenKpi }) {
interface KpiSectionProps {
kpi: HydrogenKpi;
scope?: OverviewScope;
scopeLabel?: string | null;
onDrillRequest?: (request: OverviewDrillRequest) => void;
}
export function KpiSection({ kpi: k, scope = 'global', scopeLabel, onDrillRequest }: KpiSectionProps) {
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
const yearKgFmt = fmtKg(k.yearKg);
const yearFeeFmt = fmtYuan(k.yearFee);
const yearProfitFmt = fmtYuan(k.yearProfit);
@@ -50,65 +83,22 @@ export function KpiSection({ kpi: k }: { kpi: HydrogenKpi }) {
const customerYearFee = Math.max(0, k.yearFee - k.ourYearFee);
const customerYearFeeFmt = fmtYuan(customerYearFee);
const yearRevenueFmt = fmtYuan(k.yearRevenue);
const profitColor = k.yearProfit >= 0 ? 'text-emerald-600' : 'text-red-600';
const openDrill = (key: OverviewMetricKey) => {
if (onDrillRequest) onDrillRequest({ kind: 'metric', key, label: key });
else setDrill(buildMetricDrillPayload(k, key));
};
return (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
<KpiCard
icon={<Fuel size={14} className="text-cyan-600" strokeWidth={2.4} />}
iconBg="bg-cyan-50"
accentClass="text-slate-800"
label="累计加氢量"
hero={yearKgFmt}
rows={[
{ label: '我司', value: `${ourYearKgFmt.value} ${ourYearKgFmt.unit}` },
{ label: '客户', value: `${customerYearKgFmt.value} ${customerYearKgFmt.unit}` },
]}
/>
<KpiCard
icon={<Wallet size={14} className="text-blue-600" strokeWidth={2.4} />}
iconBg="bg-blue-50"
accentClass="text-slate-800"
label="累计加氢费"
hero={{ value: `¥${yearFeeFmt.value}`, unit: yearFeeFmt.unit }}
rows={[
{ label: '我司承担', value: `¥${fmtYuan(k.ourYearFee).value} ${fmtYuan(k.ourYearFee).unit}` },
{ label: '客户承担', value: `¥${customerYearFeeFmt.value} ${customerYearFeeFmt.unit}` },
]}
/>
<KpiCard
icon={<TrendingUp size={14} className="text-emerald-600" strokeWidth={2.4} />}
iconBg="bg-emerald-50"
accentClass={profitColor}
label="时享加氢获利"
hero={{ value: `¥${yearProfitFmt.value}`, unit: yearProfitFmt.unit }}
rows={[
{ label: '收入', value: `¥${yearRevenueFmt.value} ${yearRevenueFmt.unit}` },
{ label: '成本', value: `¥${yearFeeFmt.value} ${yearFeeFmt.unit}` },
]}
/>
<KpiCard
icon={<CalendarDays size={14} className="text-amber-600" strokeWidth={2.4} />}
iconBg="bg-amber-50"
accentClass="text-amber-600"
label="本月加氢"
hero={monthKgFmt}
rows={[
{ label: '加氢费', value: `¥${monthFeeFmt.value} ${monthFeeFmt.unit}` },
{ label: '占年比', value: `${k.yearKg > 0 ? (k.monthKg / k.yearKg * 100).toFixed(1) : '0.0'}%` },
]}
/>
<KpiCard
icon={<Sparkles size={14} className="text-violet-600" strokeWidth={2.4} />}
iconBg="bg-violet-50"
accentClass="text-violet-600"
label="本日加氢"
hero={todayKgFmt}
rows={[
{ label: '加氢费', value: `¥${todayFeeFmt.value} ${todayFeeFmt.unit}` },
{ label: '占月比', value: `${k.monthKg > 0 ? (k.todayKg / k.monthKg * 100).toFixed(1) : '0.0'}%` },
]}
/>
</div>
<>
<div className="ehb-host-kpi" aria-label={`${scope === 'station' ? scopeLabel || '单站' : '全局'}经营指标`}>
<KpiCard metricKey="yearKg" icon={<Fuel size={14} />} tone="blue" label="累计加氢量" hero={yearKgFmt} rows={[{ label: '我司', value: `${ourYearKgFmt.value}${ourYearKgFmt.unit}` }, { label: '客户', value: `${customerYearKgFmt.value}${customerYearKgFmt.unit}` }]} onOpen={openDrill} />
<KpiCard metricKey="yearFee" icon={<Wallet size={14} />} tone="blue" label="累计加氢费" hero={{ value: `¥${yearFeeFmt.value}`, unit: yearFeeFmt.unit }} rows={[{ label: '我司', value: `¥${fmtYuan(k.ourYearFee).value}${fmtYuan(k.ourYearFee).unit}` }, { label: '客户', value: `¥${customerYearFeeFmt.value}${customerYearFeeFmt.unit}` }]} onOpen={openDrill} />
<KpiCard metricKey="yearProfit" icon={<Activity size={14} />} tone="green" label="客户单毛利" hero={{ value: `¥${yearProfitFmt.value}`, unit: yearProfitFmt.unit }} rows={[{ label: '收入', value: `¥${yearRevenueFmt.value}${yearRevenueFmt.unit}` }, { label: '成本', value: `¥${customerYearFeeFmt.value}${customerYearFeeFmt.unit}` }]} onOpen={openDrill} />
<KpiCard metricKey="monthKg" icon={<Truck size={14} />} tone="amber" label="本月加氢" hero={monthKgFmt} rows={[{ label: '加氢费', value: `¥${monthFeeFmt.value}${monthFeeFmt.unit}` }, { label: '占年比', value: `${k.yearKg > 0 ? (k.monthKg / k.yearKg * 100).toFixed(1) : '0.0'}%` }]} onOpen={openDrill} />
<KpiCard metricKey="todayKg" icon={<Zap size={14} />} tone="purple" label="本日加氢" hero={todayKgFmt} rows={[{ label: '加氢费', value: `¥${todayFeeFmt.value}${todayFeeFmt.unit}` }, { label: '占月比', value: `${k.monthKg > 0 ? (k.todayKg / k.monthKg * 100).toFixed(1) : '0.0'}%` }]} onOpen={openDrill} />
</div>
<OverviewDrillDialog payload={drill} onClose={() => setDrill(null)} />
</>
);
}