feat(energy): rebuild hydrogen BI board and drill-through
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -1,6 +1,13 @@
|
||||
import { AlertTriangle, Building2, Gauge } from 'lucide-react';
|
||||
import type { HydrogenMonthlyPoint } from '../../types';
|
||||
import { formatKg as fmtKg } from '../model';
|
||||
import { Activity, ChevronDown, Shield, TrendingDown } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { HydrogenMonthlyPoint, HydrogenStationFull } from '../../types';
|
||||
import {
|
||||
buildStationDrillPayload,
|
||||
formatKg as fmtKg,
|
||||
type OverviewDrillPayload,
|
||||
type OverviewDrillRequest,
|
||||
} from '../model';
|
||||
import { OverviewDrillDialog } from './OverviewDrillDialog';
|
||||
|
||||
interface InsightCardsProps {
|
||||
monthAvgKg: number;
|
||||
@@ -8,13 +15,16 @@ interface InsightCardsProps {
|
||||
latestMonth: HydrogenMonthlyPoint | undefined;
|
||||
monthMomentum: number | null;
|
||||
top5Share: number;
|
||||
profitYield: number;
|
||||
customerGrossMarginPct: number;
|
||||
stationAvgKg: number;
|
||||
stationCount: number;
|
||||
yearProfitValue: string;
|
||||
yearProfitUnit: string;
|
||||
yearRevenueValue: string;
|
||||
yearRevenueUnit: string;
|
||||
stations: HydrogenStationFull[];
|
||||
onSelectStation: (stationId: number) => void;
|
||||
onDrillRequest?: (request: OverviewDrillRequest) => void;
|
||||
}
|
||||
|
||||
export function InsightCards({
|
||||
@@ -23,66 +33,144 @@ export function InsightCards({
|
||||
latestMonth,
|
||||
monthMomentum,
|
||||
top5Share,
|
||||
profitYield,
|
||||
customerGrossMarginPct,
|
||||
stationAvgKg,
|
||||
stationCount,
|
||||
yearProfitValue,
|
||||
yearProfitUnit,
|
||||
yearRevenueValue,
|
||||
yearRevenueUnit,
|
||||
stations,
|
||||
onSelectStation,
|
||||
onDrillRequest,
|
||||
}: InsightCardsProps) {
|
||||
const [rankingOpen, setRankingOpen] = useState(false);
|
||||
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
|
||||
const [selectedStationId, setSelectedStationId] = useState<number | null>(null);
|
||||
const rankingRef = useRef<HTMLDivElement>(null);
|
||||
const highestKg = stations[0]?.kg || 1;
|
||||
useEffect(() => {
|
||||
if (!rankingOpen) return;
|
||||
const closeOnOutsideClick = (event: MouseEvent) => {
|
||||
if (rankingRef.current && !rankingRef.current.contains(event.target as Node)) setRankingOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', closeOnOutsideClick);
|
||||
return () => document.removeEventListener('mousedown', closeOnOutsideClick);
|
||||
}, [rankingOpen]);
|
||||
const openStation = (station: HydrogenStationFull) => {
|
||||
setRankingOpen(false);
|
||||
setSelectedStationId(station.id);
|
||||
if (onDrillRequest) onDrillRequest({ kind: 'station', key: String(station.id), label: station.name, entityId: station.id });
|
||||
else setDrill(buildStationDrillPayload(station));
|
||||
};
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 md:gap-3">
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[11px] font-black text-slate-400">月度动能</div>
|
||||
<div className="mt-1 text-lg font-black text-slate-900">
|
||||
{monthMomentum === null ? '暂无对比' : `${monthMomentum >= 0 ? '+' : ''}${monthMomentum.toFixed(1)}%`}
|
||||
</div>
|
||||
</div>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-blue-50 text-blue-600 ring-1 ring-blue-100">
|
||||
<Gauge size={18} />
|
||||
</span>
|
||||
<>
|
||||
<div className="ehb-insight" aria-label="经营洞察">
|
||||
<div className="ehb-insight__card">
|
||||
<div className="ehb-insight__icon is-down">
|
||||
<TrendingDown size={18} aria-hidden />
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
|
||||
{latestMonth ? `${latestMonth.month} 加氢 ${fmtKg(latestMonth.kg).value}${fmtKg(latestMonth.kg).unit}` : '暂无月度数据'}
|
||||
{bestMonth ? ` · 峰值 ${bestMonth.month}` : ''}
|
||||
{monthAvgKg > 0 ? ` · 月均 ${fmtKg(monthAvgKg).value}${fmtKg(monthAvgKg).unit}` : ''}
|
||||
<div>
|
||||
<div className="ehb-insight__title">月度加氢异常波动</div>
|
||||
<div className="ehb-insight__value is-neg">
|
||||
{monthMomentum === null ? '暂无对比' : `${monthMomentum >= 0 ? '+' : ''}${monthMomentum.toFixed(1)}%`}
|
||||
</div>
|
||||
<div className="ehb-insight__desc">
|
||||
{latestMonth ? `${latestMonth.month} 加氢 ${fmtKg(latestMonth.kg).value}${fmtKg(latestMonth.kg).unit}` : '暂无月度数据'}
|
||||
{bestMonth ? ` · 峰值 ${bestMonth.month}` : ''}
|
||||
{monthAvgKg > 0 ? ` · 月均 ${fmtKg(monthAvgKg).value}${fmtKg(monthAvgKg).unit}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[11px] font-black text-slate-400">站点集中度</div>
|
||||
<div className="mt-1 text-lg font-black text-slate-900">Top5 {top5Share.toFixed(1)}%</div>
|
||||
|
||||
<div
|
||||
className={`ehb-insight__card ehb-insight__card--rank ${rankingOpen ? 'is-open' : ''}`}
|
||||
ref={rankingRef}
|
||||
onClick={() => setRankingOpen((v) => !v)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
title="点击查看加氢站加氢量排名"
|
||||
>
|
||||
<div className="ehb-insight__icon">
|
||||
<Shield size={18} aria-hidden />
|
||||
</div>
|
||||
<div className="ehb-insight__rank-body">
|
||||
<div className="ehb-insight__title">头部加氢站占比</div>
|
||||
<div className="ehb-insight__value">Top5 {top5Share.toFixed(1)}%</div>
|
||||
<div className="ehb-insight__desc">
|
||||
共 {stationCount} 站 · 单站年均 {fmtKg(stationAvgKg).value}{fmtKg(stationAvgKg).unit} · 点击展开加氢量排名
|
||||
</div>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-cyan-50 text-cyan-600 ring-1 ring-cyan-100">
|
||||
<Building2 size={18} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
|
||||
共 {stationCount} 站 · 单站年均 {fmtKg(stationAvgKg).value}{fmtKg(stationAvgKg).unit}
|
||||
{top5Share >= 70 ? ' · 头部站点依赖偏高' : ' · 分布相对健康'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[11px] font-black text-slate-400">收支健康度</div>
|
||||
<div className={`mt-1 text-lg font-black ${profitYield >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
|
||||
{profitYield.toFixed(1)}%
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`ehb-insight__rank-chevron ${rankingOpen ? 'is-open' : ''}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{rankingOpen && (
|
||||
<div
|
||||
className="ehb-station-rank-dropdown"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="listbox"
|
||||
aria-label="加氢站加氢量排名"
|
||||
>
|
||||
<div className="ehb-station-rank-dropdown__head">
|
||||
<span>加氢站加氢量排名</span>
|
||||
<span className="ehb-station-rank-dropdown__meta">
|
||||
高 → 低 · 共 {stationCount} 站
|
||||
</span>
|
||||
</div>
|
||||
<div className="ehb-station-rank-dropdown__list">
|
||||
{stations.map((station, index) => (
|
||||
<button
|
||||
key={`${station.id}-${station.name}-${index}`}
|
||||
type="button"
|
||||
className="ehb-station-rank-item"
|
||||
onClick={() => openStation(station)}
|
||||
title="点击钻取该站明细"
|
||||
>
|
||||
<span className={`ehb-station-rank-item__rank ${index < 3 ? 'is-top' : ''}`}>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="ehb-station-rank-item__main">
|
||||
<span className="ehb-station-rank-item__name">{station.name}</span>
|
||||
<span className="ehb-station-rank-item__bar">
|
||||
<span style={{ width: `${Math.max(2, station.kg / highestKg * 100)}%` }} />
|
||||
</span>
|
||||
</span>
|
||||
<span className="ehb-station-rank-item__val">
|
||||
{fmtKg(station.kg).value} {fmtKg(station.kg).unit}
|
||||
</span>
|
||||
<span className="ehb-station-rank-item__share">{(station.share * 100).toFixed(1)}%</span>
|
||||
</button>
|
||||
))}
|
||||
{stations.length === 0 && (
|
||||
<div className="ehb-station-rank-empty">当前筛选下暂无站点数据</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`flex h-9 w-9 items-center justify-center rounded-xl ring-1 ${profitYield >= 0 ? 'bg-emerald-50 text-emerald-600 ring-emerald-100' : 'bg-rose-50 text-rose-600 ring-rose-100'}`}>
|
||||
<AlertTriangle size={18} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ehb-insight__card">
|
||||
<div className={`ehb-insight__icon ${customerGrossMarginPct >= 0 ? 'is-ok' : 'is-neg'}`}>
|
||||
<Activity size={18} aria-hidden />
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
|
||||
时享获利 {yearProfitValue}{yearProfitUnit} · 客户收入 {yearRevenueValue}{yearRevenueUnit}
|
||||
{profitYield < 0 ? ' · 需关注亏损站点与客户价格' : ' · 当前保持正向收益'}
|
||||
<div>
|
||||
<div className="ehb-insight__title">客户单毛利率</div>
|
||||
<div className={`ehb-insight__value ${customerGrossMarginPct >= 0 ? 'is-pos' : 'is-neg'}`}>
|
||||
{customerGrossMarginPct.toFixed(1)}%
|
||||
</div>
|
||||
<div className="ehb-insight__desc">
|
||||
客户单毛利 {yearProfitValue}{yearProfitUnit} · 客户收入 {yearRevenueValue}{yearRevenueUnit}
|
||||
{customerGrossMarginPct < 0 ? ' · 需关注客户价格与站点成本' : ' · 当前客户单保持正毛利'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<OverviewDrillDialog
|
||||
payload={drill}
|
||||
onClose={() => { setDrill(null); setSelectedStationId(null); }}
|
||||
onPrimaryAction={selectedStationId ? () => { const stationId = selectedStationId; setDrill(null); setSelectedStationId(null); onSelectStation(stationId); } : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user