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,55 +1,116 @@
import type { HydrogenCustomerRow, HydrogenStationFull } from '../../types';
import { formatKg as fmtKg, formatYuan as fmtYuan } from '../model';
import { useMemo, useState } from 'react';
import { ChevronRight, Search } from 'lucide-react';
import { SortableColumnHeader, sortBy, toggleSort, type SortDirection } from '../../components/SortableColumnHeader';
import {
buildCustomerDrillPayload,
buildStationDrillPayload,
formatKg as fmtKg,
formatYuan as fmtYuan,
type OverviewDrillPayload,
type OverviewDrillRequest,
type OverviewScope,
} from '../model';
import { OverviewDrillDialog } from './OverviewDrillDialog';
export function StationSummaryTable({ stations }: { stations: HydrogenStationFull[] }) {
export function StationSummaryTable({
stations,
onSelectStation,
scope = 'global',
scopeLabel,
onDrillRequest,
}: {
stations: HydrogenStationFull[];
onSelectStation?: (stationId: number) => void;
scope?: OverviewScope;
scopeLabel?: string | null;
onDrillRequest?: (request: OverviewDrillRequest) => void;
}) {
const [province, setProvince] = useState('all');
const [sortKey, setSortKey] = useState<'name' | 'province' | 'kg' | 'share' | 'revenue' | 'revenueShare'>('kg');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
const [selectedStationId, setSelectedStationId] = useState<number | null>(null);
const provinces = useMemo(() => [...new Set(stations.map(station => station.province?.trim()).filter(Boolean) as string[])], [stations]);
const filteredStations = province === 'all'
? stations
: stations.filter(station => station.province === province);
const sortedStations = useMemo(() => sortBy(filteredStations, sortKey, sortDirection, (station, key) => station[key]), [filteredStations, sortDirection, sortKey]);
const changeSort = (nextKey: typeof sortKey) => {
const next = toggleSort(sortKey, sortDirection, nextKey);
setSortKey(next.key);
setSortDirection(next.direction);
};
const openStation = (station: HydrogenStationFull) => {
setSelectedStationId(station.id);
if (onDrillRequest) onDrillRequest({ kind: 'station', key: String(station.id), label: station.name, entityId: station.id });
else setDrill(buildStationDrillPayload(station));
};
return (
<>
{stations.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700">加氢站加氢汇总</span>
<span className="text-[11px] text-slate-400 font-bold">共 {stations.length} 站</span>
<div className="ehb-sum-table-card">
<div className="ehb-sum-table-card__head" style={{ flexWrap: 'wrap', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div className="ehb-sum-table-card__title"><Search size={14} className="text-sky-600" />加氢站加氢汇总{scope === 'station' && scopeLabel ? ` · ${scopeLabel}` : ''}</div>
<div className="ehb-mini-tabs">
<button type="button" onClick={() => setProvince('all')} className={`ehb-mini-tab ${province === 'all' ? 'is-active' : ''}`}>全国</button>
{provinces.map(item => <button key={item} type="button" onClick={() => setProvince(item)} className={`ehb-mini-tab ${province === item ? 'is-active' : ''}`}>{item}</button>)}
</div>
</div>
<div className="ehb-sum-table-card__meta">
统计范围内共 {stations.length} 站
</div>
</div>
<div className="overflow-x-auto -mx-1 px-1">
<table className="w-full text-[11px]">
<div className="ehb-sum-table-wrap">
<table className="ehb-sum-table">
<thead>
<tr className="text-slate-400 font-bold border-b border-slate-100">
<th className="text-left py-1.5 pl-1 w-8">#</th>
<th className="text-left py-1.5">加氢站</th>
<th className="text-right py-1.5 w-20">加氢量</th>
<th className="text-right py-1.5 pl-2 hidden sm:table-cell">占比</th>
<th className="text-right py-1.5 pl-2 w-24">氢费收入</th>
<th className="text-right py-1.5 pr-1 hidden md:table-cell">收入占比</th>
<tr>
<th className="col-idx">#</th>
<th style={{ textAlign: 'left' }}><SortableColumnHeader label="加氢站" sortKey="name" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
<th style={{ textAlign: 'left' }}><SortableColumnHeader label="所属省份" sortKey="province" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="加氢量" sortKey="kg" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="占比" sortKey="share" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="氢费收入" sortKey="revenue" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="收入占比" sortKey="revenueShare" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
</tr>
</thead>
<tbody>
{stations.map((s, i) => {
{sortedStations.map((s, i) => {
const kgFmt = fmtKg(s.kg);
const revFmt = fmtYuan(s.revenue);
return (
<tr key={s.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
<td className="py-1.5 text-slate-700 truncate max-w-[180px]">{s.name}</td>
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
<tr key={s.name + i} onClick={() => openStation(s)} style={{ cursor: 'pointer' }} title="查看加氢站汇总明细">
<td className="col-idx">{i + 1}</td>
<td style={{ fontWeight: 600, color: '#0284c7' }}>
{s.name} <span style={{ fontSize: 11, fontWeight: 400, opacity: 0.8 }}>钻取 ›</span>
</td>
<td className="py-1.5 pl-2 text-right hidden sm:table-cell">
<div className="inline-flex items-center gap-1.5">
<div className="w-12 h-1 bg-slate-100 rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-cyan-400 to-blue-500" style={{ width: `${Math.min(100, s.share * 100)}%` }} />
<td>
<span style={{ fontSize: 11, color: '#0284c7', background: '#eff6ff', padding: '1px 6px', borderRadius: 4, fontWeight: 500 }}>
{s.province ?? '未归属'}
</span>
</td>
<td style={{ textAlign: 'right' }} className="col-bold-kg">
{kgFmt.value} <span style={{ fontSize: 11, fontWeight: 400, color: '#64748b' }}>{kgFmt.unit}</span>
</td>
<td>
<div className="ehb-ratio-flex">
<div className="ehb-mini-bar-track">
<div className="ehb-mini-bar-fill is-blue" style={{ width: `${Math.min(100, s.share * 100 * 2.5)}%` }} />
</div>
<span className="text-slate-500 tabular-nums">{(s.share * 100).toFixed(1)}%</span>
<span className="ehb-ratio-text">{(s.share * 100).toFixed(1)}%</span>
</div>
</td>
<td className="py-1.5 pl-2 text-right tabular-nums font-bold text-emerald-600">
¥{revFmt.value}<span className="text-slate-400 font-normal ml-0.5">{revFmt.unit}</span>
<td style={{ textAlign: 'right' }} className="col-green-fee">
¥{revFmt.value} <span style={{ fontSize: 11, fontWeight: 400 }}>{revFmt.unit}</span>
</td>
<td className="py-1.5 pr-1 text-right hidden md:table-cell">
<div className="inline-flex items-center gap-1.5">
<div className="w-12 h-1 bg-slate-100 rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-emerald-400 to-emerald-600" style={{ width: `${Math.min(100, s.revenueShare * 100)}%` }} />
<td>
<div className="ehb-ratio-flex">
<div className="ehb-mini-bar-track">
<div className="ehb-mini-bar-fill is-green" style={{ width: `${Math.min(100, s.revenueShare * 100 * 5)}%` }} />
</div>
<span className="text-slate-500 tabular-nums">{(s.revenueShare * 100).toFixed(1)}%</span>
<span className="ehb-ratio-text">{(s.revenueShare * 100).toFixed(1)}%</span>
</div>
</td>
</tr>
@@ -60,55 +121,98 @@ export function StationSummaryTable({ stations }: { stations: HydrogenStationFul
</div>
</div>
)}
<OverviewDrillDialog
payload={drill}
onClose={() => { setDrill(null); setSelectedStationId(null); }}
onPrimaryAction={selectedStationId && onSelectStation ? () => { const stationId = selectedStationId; setDrill(null); setSelectedStationId(null); onSelectStation(stationId); } : undefined}
/>
</>
);
}
export function CustomerSummaryTable({ customers }: { customers: HydrogenCustomerRow[] }) {
export function CustomerSummaryTable({
customers,
scope = 'global',
scopeLabel,
onDrillRequest,
}: {
customers: HydrogenCustomerRow[];
scope?: OverviewScope;
scopeLabel?: string | null;
onDrillRequest?: (request: OverviewDrillRequest) => void;
}) {
const [sortKey, setSortKey] = useState<'name' | 'payer' | 'kg' | 'cost' | 'revenue'>('kg');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
const sortedCustomers = useMemo(() => sortBy(customers, sortKey, sortDirection, (customer, key) => customer[key]), [customers, sortDirection, sortKey]);
const changeSort = (nextKey: typeof sortKey) => {
const next = toggleSort(sortKey, sortDirection, nextKey);
setSortKey(next.key);
setSortDirection(next.direction);
};
const openCustomer = (customer: HydrogenCustomerRow, index: number) => {
if (onDrillRequest) onDrillRequest({ kind: 'customer', key: `${customer.name}-${index}`, label: customer.name });
else setDrill(buildCustomerDrillPayload(customer));
};
return (
<>
{customers.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700">客户账单汇总</span>
<span className="text-[11px] text-slate-400 font-bold">Top {customers.length}</span>
<div className="ehb-sum-table-card">
<div className="ehb-sum-table-card__head">
<div className="ehb-sum-table-card__title">
<Search size={14} className="text-sky-600" style={{ display: 'inline-block', verticalAlign: 'middle', marginRight: 6 }} />
客户账单汇总{scope === 'station' && scopeLabel ? ` · ${scopeLabel}` : ''}
<span className="ehb-title-sub ehb-hide-h5">
(已收 / 未收:等待客户能源账户和对账单打通后获取)
</span>
<span className="ehb-title-sub ehb-show-h5">
(已收未收打通中)
</span>
</div>
<div className="ehb-sum-table-card__meta">
Top {customers.length}
</div>
</div>
<div className="overflow-x-auto -mx-1 px-1">
<table className="w-full text-[11px]">
<div className="ehb-sum-table-wrap">
<table className="ehb-sum-table">
<thead>
<tr className="text-slate-400 font-bold border-b border-slate-100">
<th className="text-left py-1.5 pl-1 w-8">#</th>
<th className="text-left py-1.5">客户</th>
<th className="text-center py-1.5 w-14 hidden sm:table-cell">承担方</th>
<th className="text-right py-1.5 w-20">加氢量</th>
<th className="text-right py-1.5 pl-2 w-24">成本支出</th>
<th className="text-right py-1.5 pr-1 w-24 hidden md:table-cell">应收</th>
<tr>
<th className="col-idx">#</th>
<th style={{ textAlign: 'left' }}><SortableColumnHeader label="客户" sortKey="name" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
<th style={{ textAlign: 'center' }}><SortableColumnHeader label="承担方" sortKey="payer" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="center" /></th>
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="加氢量" sortKey="kg" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="成本支出" sortKey="cost" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="应收" sortKey="revenue" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
</tr>
</thead>
<tbody>
{customers.map((c2, i) => {
{sortedCustomers.map((c2, i) => {
const kgFmt = fmtKg(c2.kg);
const costFmt = fmtYuan(c2.cost);
const revFmt = fmtYuan(c2.revenue);
return (
<tr key={c2.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
<td className="py-1.5 text-slate-700 truncate max-w-[200px]">{c2.name}</td>
<td className="py-1.5 text-center hidden sm:table-cell">
<tr key={c2.name + i} onClick={() => openCustomer(c2, i)} style={{ cursor: 'pointer' }} title="点击查看客户账单明细">
<td className="col-idx">{i + 1}</td>
<td style={{ fontWeight: 600, color: '#0284c7' }}>
{c2.name} <span style={{ fontSize: 11, fontWeight: 400, opacity: 0.8 }}>钻取 ›</span>
</td>
<td style={{ textAlign: 'center' }}>
{c2.payer === 'lingniu' ? (
<span className="px-1.5 py-0.5 rounded bg-blue-50 text-blue-600 text-[10px] font-bold">羚牛</span>
<span className="ehb-payer-tag is-own">羚牛</span>
) : c2.payer === 'mixed' ? (
<span className="ehb-payer-tag is-mix">混合</span>
) : (
<span className="px-1.5 py-0.5 rounded bg-amber-50 text-amber-600 text-[10px] font-bold">客户</span>
<span className="ehb-payer-tag is-ext">客户</span>
)}
</td>
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
<td style={{ textAlign: 'right' }} className="col-bold-kg">
{kgFmt.value} <span style={{ fontSize: 11, fontWeight: 400, color: '#64748b' }}>{kgFmt.unit}</span>
</td>
<td className="py-1.5 pl-2 text-right tabular-nums text-amber-600 font-bold">
¥{costFmt.value}<span className="text-slate-400 font-normal ml-0.5">{costFmt.unit}</span>
<td style={{ textAlign: 'right' }} className="col-amber-fee">
¥{costFmt.value} <span style={{ fontSize: 11, fontWeight: 400 }}>{costFmt.unit}</span>
</td>
<td className="py-1.5 pr-1 text-right tabular-nums text-emerald-600 font-bold hidden md:table-cell">
¥{revFmt.value}<span className="text-slate-400 font-normal ml-0.5">{revFmt.unit}</span>
<td style={{ textAlign: 'right' }} className="col-green-fee">
¥{revFmt.value} <span style={{ fontSize: 11, fontWeight: 400 }}>{revFmt.unit}</span>
</td>
</tr>
);
@@ -118,6 +222,7 @@ export function CustomerSummaryTable({ customers }: { customers: HydrogenCustome
</div>
</div>
)}
<OverviewDrillDialog payload={drill} onClose={() => setDrill(null)} />
</>
);
}