Files
ln-bi/src/modules/energy/hydrogen-bi-v2/HydrogenBiV2App.tsx
T
kkfluous 62efef0ab9
ci/woodpecker/push/woodpecker Pipeline was successful
feat(energy): rebuild hydrogen BI board and drill-through
2026-08-20 13:59:03 +08:00

1650 lines
53 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
Activity,
CalendarDays,
ChevronDown,
ChevronLeft,
Download,
Fuel,
RefreshCw,
Search,
Shield,
TrendingDown,
TrendingUp,
Truck,
Wallet,
X,
Zap,
} from "lucide-react";
import {
Bar,
BarChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import {
fetchH2BiDaily,
fetchH2BiDrill,
fetchH2BiMeta,
fetchH2BiOverview,
} from "./api";
import type {
H2BiDailyResponse,
H2BiDrillMetric,
H2BiDrillRecord,
H2BiDrillResponse,
H2BiKpis,
H2BiMetaResponse,
H2BiOverviewResponse,
H2BiQuery,
H2BiScope,
H2BiStationRow,
H2BiVerifyScope,
H2BiVehicleScope,
H2BiView,
} from "./types";
import "../styles/energy-bi-board.css";
const now = new Date();
const isoDate = (date: Date) => date.toISOString().slice(0, 10);
const defaultEndDate = isoDate(now);
const defaultStart = new Date(now);
defaultStart.setDate(defaultStart.getDate() - 14);
const defaultStartDate = isoDate(defaultStart);
function number(value: number | null | undefined, digits = 0) {
return Number(value ?? 0).toLocaleString("zh-CN", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
function kg(value: number | null | undefined) {
return `${number(value, 2)} Kg`;
}
function yuan(value: number | null | undefined) {
return ${number(value, 2)}`;
}
function toT(value: number | null | undefined) {
return number((value ?? 0) / 1000, 2);
}
function toWan(value: number | null | undefined) {
return number((value ?? 0) / 10000, 2);
}
function rangeText(range?: {
startDate: string | null;
endDate: string | null;
}) {
if (!range) return "加载中…";
return (
[range.startDate, range.endDate].filter(Boolean).join(" 至 ") ||
"暂无时间范围"
);
}
function apiError(error: unknown) {
return error instanceof Error ? error.message : "数据加载失败,请刷新后重试";
}
function emptyKpis(): H2BiKpis {
return {
totalKg: 0,
totalCost: 0,
customerBearingKg: 0,
companyBearingKg: 0,
otherBearingKg: 0,
customerRevenue: 0,
customerCost: 0,
companyCost: 0,
otherCost: 0,
totalRevenue: 0,
customerGrossProfit: 0,
monthKg: 0,
monthCost: 0,
todayKg: 0,
todayCost: 0,
monthShareOfRange: 0,
todayShareOfMonth: 0,
recordCount: 0,
stationCount: 0,
};
}
interface DataState<T> {
data: T | null;
error: string | null;
loading: boolean;
}
function useRemoteData<T>(key: string, load: () => Promise<T>) {
const [state, setState] = useState<DataState<T>>({
data: null,
error: null,
loading: true,
});
useEffect(() => {
let active = true;
setState((previous) => ({ ...previous, error: null, loading: true }));
void load()
.then((data) => active && setState({ data, error: null, loading: false }))
.catch(
(error: unknown) =>
active &&
setState((previous) => ({
...previous,
error: apiError(error),
loading: false,
})),
);
return () => {
active = false;
};
}, [key]); // caller supplies the query-derived key deliberately
return state;
}
function KpiCard({
label,
value,
prefix,
unit,
left,
right,
icon,
tone,
onClick,
}: {
label: string;
value: string;
prefix?: string;
unit: string;
left: string;
right: string;
icon: ReactNode;
tone: "blue" | "green" | "amber" | "purple" | "cyan";
onClick: () => void;
}) {
return (
<button
type="button"
className="ehb-kpi-dual"
onClick={onClick}
title={`查看${label}明细`}
>
<span className="ehb-kpi-dual__head">
<span className="ehb-kpi-dual__label">{label}</span>
<span className={`ehb-kpi-dual__badge is-${tone}`}>{icon}</span>
</span>
<span className="ehb-kpi-dual__val">
{prefix ? <span className="ehb-kpi-dual__symbol">{prefix}</span> : null}
<span className="ehb-kpi-dual__num">{value}</span>
<span className="ehb-kpi-dual__unit">{unit}</span>
</span>
<span className="ehb-kpi-dual__deck">
<span title={left}>{left}</span>
<span title={right}>{right}</span>
</span>
</button>
);
}
function PrototypeYearSelect({
value,
years,
onChange,
}: {
value: number;
years: number[];
onChange: (year: number) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const close = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node))
setOpen(false);
};
if (open) document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [open]);
return (
<div className="ehb-year-select-wrapper" ref={ref}>
<button
type="button"
className={`ehb-year-select-btn ${open ? "is-active" : ""}`}
onClick={() => setOpen((shown) => !shown)}
aria-label="年份选择"
>
<span className="ehb-year-text">{value} </span>
<ChevronDown
size={13}
style={{
transition: "transform 0.2s ease",
transform: open ? "rotate(180deg)" : "none",
color: "#64748b",
}}
/>
</button>
{open ? (
<div className="ehb-year-dropdown">
<div className="ehb-year-dropdown__header">切换数据年份</div>
<div className="ehb-year-dropdown__list">
{years.map((year) => (
<button
type="button"
key={year}
className={`ehb-year-dropdown__item ${year === value ? "is-selected" : ""}`}
onClick={() => {
onChange(year);
setOpen(false);
}}
>
<span>{year} </span>
{year === value ? (
<span className="ehb-year-check"></span>
) : null}
</button>
))}
</div>
</div>
) : null}
</div>
);
}
function EmptyTable({ children }: { children: ReactNode }) {
return (
<div className="ehb-empty">
<div className="ehb-empty__title">{children}</div>
</div>
);
}
function TableCard({
title,
children,
hint,
}: {
title: string;
children: ReactNode;
hint?: string;
}) {
return (
<section className="ehb-table-card">
<div className="ehb-table-card__head">
<h2 className="ehb-table-card__title">{title}</h2>
{hint ? <span className="ehb-table-card__hint">{hint}</span> : null}
</div>
<div className="ehb-table-scroll">{children}</div>
</section>
);
}
function OverviewRankings({
overview,
onDrill,
}: {
overview: H2BiOverviewResponse;
onDrill: (
metric: H2BiDrillMetric,
stationId?: string | number | null,
) => void;
}) {
const [granularity, setGranularity] = useState<"province" | "city">("city");
const total = overview.kpis.totalKg || 1;
const source =
granularity === "city"
? overview.stations.reduce<Record<string, number>>(
(acc, station) => ({
...acc,
[station.city || "未归属区域"]:
(acc[station.city || "未归属区域"] || 0) + station.kg,
}),
{},
)
: overview.stations.reduce<Record<string, number>>(
(acc, station) => ({
...acc,
[station.province || "未归属"]:
(acc[station.province || "未归属"] || 0) + station.kg,
}),
{},
);
const colors = [
"#0284c7",
"#38bdf8",
"#10b981",
"#f59e0b",
"#8b5cf6",
"#ec4899",
"#06b6d4",
"#84cc16",
"#94a3b8",
];
const regions = Object.entries(source)
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([region, kg], index) => ({ region, kg, color: colors[index] }));
const rest = total - regions.reduce((sum, region) => sum + region.kg, 0);
if (rest > 0)
regions.push({
region: `其他${granularity === "city" ? "城市" : "省份"}`,
kg: rest,
color: "#94a3b8",
});
let offset = 0;
return (
<div className="ehb-two-charts-row">
<div className="ehb-chart-box">
<div className="ehb-chart-box-head">
<div className="ehb-chart-box-title">加氢站加氢量 Top5</div>
<div className="ehb-chart-legend-inline">
<span className="ehb-chart-legend-tag">
<i className="ehb-legend-sq" style={{ background: "#0284c7" }} />
内部客户
</span>
<span className="ehb-chart-legend-tag">
<i className="ehb-legend-sq" style={{ background: "#f59e0b" }} />
外部客户
</span>
</div>
</div>
<div className="ehb-top-stations-list">
{overview.topStations.map((station, index) => {
const ratio = station.kg
? (station.lingniuKg / station.kg) * 100
: 0;
return (
<button
type="button"
key={`${station.id}-${station.name}`}
className="ehb-top-station-item"
onClick={() => onDrill("station", station.id)}
title="点击钻取该加氢站明细"
>
<span className={`ehb-top-rank ${index > 1 ? "is-sub" : ""}`}>
{index + 1}
</span>
<span className="ehb-top-station-name">{station.name}</span>
<span className="ehb-top-bar-bg">
<span
className="ehb-top-bar-fill"
style={{
width: `${overview.topStations[0]?.kg ? (station.kg / overview.topStations[0].kg) * 100 : 0}%`,
}}
>
<i
className="ehb-top-bar-seg is-own"
style={{ width: `${ratio}%` }}
/>
<i
className="ehb-top-bar-seg is-ext"
style={{ width: `${100 - ratio}%` }}
/>
</span>
</span>
<span className="ehb-top-station-val">
{number(station.kg)}
</span>
</button>
);
})}
</div>
</div>
<div className="ehb-chart-box">
<div className="ehb-chart-box-head">
<div className="ehb-chart-box-title">各区域加氢占比</div>
<div className="ehb-mini-tabs">
<button
type="button"
className={`ehb-mini-tab ${granularity === "province" ? "is-active" : ""}`}
onClick={() => setGranularity("province")}
>
按省
</button>
<button
type="button"
className={`ehb-mini-tab ${granularity === "city" ? "is-active" : ""}`}
onClick={() => setGranularity("city")}
>
按市
</button>
</div>
</div>
<div className="ehb-donut-section">
<div className="ehb-donut-chart-wrap">
<svg width="130" height="130" viewBox="0 0 100 100">
<circle
cx="50"
cy="50"
r="38"
fill="none"
stroke="#f1f5f9"
strokeWidth="16"
/>
{regions.map((region) => {
const dash = (region.kg / total) * 238.76;
const current = offset;
offset += dash;
return (
<circle
key={region.region}
cx="50"
cy="50"
r="38"
fill="none"
stroke={region.color}
strokeWidth="16"
strokeDasharray={`${dash} ${238.76 - dash}`}
strokeDashoffset={-current}
transform="rotate(-90 50 50)"
/>
);
})}
</svg>
<div className="ehb-donut-center-text">
<div className="title">年合计</div>
<div className="val">{toT(total)}T</div>
</div>
</div>
<div className="ehb-region-legend-grid">
{regions.map((region) => (
<button
type="button"
className="ehb-region-legend-item"
key={region.region}
onClick={() => onDrill("station")}
>
<span className="ehb-region-legend-left">
<i
className="ehb-region-dot"
style={{ background: region.color }}
/>
{region.region}
</span>
<strong className="ehb-region-legend-val">
{number((region.kg / total) * 100, 1)}%
</strong>
</button>
))}
</div>
</div>
</div>
</div>
);
}
function MonthlyRevenueComparison({
overview,
onDrill,
}: {
overview: H2BiOverviewResponse;
onDrill: (metric: H2BiDrillMetric) => void;
}) {
const max = Math.max(
...overview.monthly.flatMap((row) => [
row.customerRevenue,
row.customerCost,
]),
1,
);
const year = overview.range.startDate?.slice(0, 4) || "当前";
return (
<section className="ehb-overview-charts">
<div className="ehb-chart-box">
<div className="ehb-chart-box-head">
<div className="ehb-chart-box-title">{year} 年月度收支对比</div>
<div className="ehb-chart-legend-inline">
<span className="ehb-chart-legend-tag">
<i className="ehb-legend-sq is-income" />
对客金额
</span>
<span className="ehb-chart-legend-tag">
<i className="ehb-legend-sq is-cost" />
客户承担成本金额
</span>
<span className="ehb-chart-box-meta">
统计范围:{rangeText(overview.range)} · 单位
</span>
</div>
</div>
<div className="ehb-rev-chart">
{overview.monthly.map((row) => (
<div key={row.month} className="ehb-rev-col-group">
<div className="ehb-rev-bars">
<button
type="button"
className="ehb-rev-bar is-income"
onClick={() => onDrill("customerGrossProfit")}
style={{
height: `${Math.max(row.customerRevenue ? 3 : 0, (row.customerRevenue / max) * 100)}%`,
}}
title={`${row.month} 对客金额 ${yuan(row.customerRevenue)}`}
/>
<button
type="button"
className="ehb-rev-bar is-cost"
onClick={() => onDrill("customerGrossProfit")}
style={{
height: `${Math.max(row.customerCost ? 3 : 0, (row.customerCost / max) * 100)}%`,
}}
title={`${row.month} 客户承担成本金额 ${yuan(row.customerCost)}`}
/>
</div>
<div className="ehb-rev-label">{row.month}</div>
</div>
))}
</div>
</div>
</section>
);
}
function OverviewTables({
overview,
onDrill,
}: {
overview: H2BiOverviewResponse;
onDrill: (
metric: H2BiDrillMetric,
stationId?: string | number | null,
) => void;
}) {
const [province, setProvince] = useState("全国");
const provinces = [
"全国",
...Array.from(
new Set(
overview.stations
.map((station) => station.province)
.filter((value): value is string => Boolean(value)),
),
),
];
const stations =
province === "全国"
? overview.stations
: overview.stations.filter((station) => station.province === province);
const totalKg = overview.kpis.totalKg || 1;
const totalRevenue = overview.kpis.customerRevenue || 1;
return (
<>
<section 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">加氢站加氢汇总</div>
<div className="ehb-mini-tabs">
{provinces.map((item) => (
<button
type="button"
className={`ehb-mini-tab ${province === item ? "is-active" : ""}`}
key={item}
onClick={() => setProvince(item)}
>
{item}
</button>
))}
</div>
</div>
<div className="ehb-sum-table-card__meta">
统计范围:{rangeText(overview.range)} · {stations.length}
</div>
</div>
<div className="ehb-sum-table-wrap">
<table className="ehb-sum-table">
<thead>
<tr>
<th>#</th>
<th>加氢站(点击钻取)</th>
<th>所属省份</th>
<th>加氢量</th>
<th>占比</th>
<th>对客金额</th>
<th>收入占比</th>
</tr>
</thead>
<tbody>
{stations.map((row, index) => (
<tr
key={`${row.id}-${row.name}`}
onClick={() => onDrill("station", row.id)}
style={{ cursor: "pointer" }}
>
<td>{index + 1}</td>
<td style={{ fontWeight: 600, color: "#0284c7" }}>
{row.name}{" "}
<span className="ehb-kpi-drill-hint">钻取 </span>
</td>
<td>
<span
className="ehb-region-dot"
style={{ background: "#e0f2fe" }}
/>
{row.province || "未归属"}
</td>
<td className="col-bold-kg">{toT(row.kg)} T</td>
<td>
<div className="ehb-ratio-flex">
<i className="ehb-mini-bar-track">
<i
className="ehb-mini-bar-fill is-blue"
style={{ width: `${(row.kg / totalKg) * 100}%` }}
/>
</i>
{number((row.kg / totalKg) * 100, 1)}%
</div>
</td>
<td className="col-green-fee">¥{toWan(row.customerRevenue)} 万元</td>
<td>
<div className="ehb-ratio-flex">
<i className="ehb-mini-bar-track">
<i
className="ehb-mini-bar-fill is-green"
style={{
width: `${(row.customerRevenue / totalRevenue) * 100}%`,
}}
/>
</i>
{number((row.customerRevenue / totalRevenue) * 100, 1)}%
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className="ehb-sum-table-card">
<div className="ehb-sum-table-card__head">
<div className="ehb-sum-table-card__title">客户账单汇总</div>
<div className="ehb-sum-table-card__meta">
统计范围:{rangeText(overview.range)} · Top{" "}
{overview.customers.length}
</div>
</div>
<div className="ehb-sum-table-wrap">
<table className="ehb-sum-table">
<thead>
<tr>
<th>#</th>
<th>客户(点击钻取)</th>
<th>加氢量</th>
<th>客户承担成本</th>
<th>对客金额</th>
</tr>
</thead>
<tbody>
{overview.customers.map((row, index) => (
<tr
key={`${row.id}-${row.name}`}
onClick={() => onDrill("customer")}
style={{ cursor: "pointer" }}
>
<td>{index + 1}</td>
<td style={{ fontWeight: 600, color: "#0284c7" }}>
{row.name}{" "}
<span className="ehb-kpi-drill-hint">钻取 </span>
</td>
<td className="col-bold-kg">{toT(row.kg)} T</td>
<td className="col-orange-cost">¥{toWan(row.customerCost)} 万元</td>
<td className="col-green-fee">¥{toWan(row.customerRevenue)} 万元</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</>
);
}
function OverviewPage({
overview,
onDrill,
}: {
overview: H2BiOverviewResponse;
onDrill: (
metric: H2BiDrillMetric,
stationId?: string | number | null,
) => void;
}) {
const kpis = overview.kpis || emptyKpis();
const firstStation = overview.topStations[0] as H2BiStationRow | undefined;
const top5Share = overview.topStations.reduce(
(sum, station) => sum + station.share,
0,
);
const profitRate = kpis.customerRevenue
? (kpis.customerGrossProfit / kpis.customerRevenue) * 100
: 0;
return (
<>
<section className="ehb-host" aria-label="氢能经营总览">
<div className="ehb-host-kpi">
<KpiCard
label="累计加氢量"
value={toT(kpis.totalKg)}
unit="T"
left={`羚牛 ${toT(overview.monthly.reduce((sum, row) => sum + row.lingniuKg, 0))} T`}
right={`外部 ${toT(overview.monthly.reduce((sum, row) => sum + row.externalKg, 0))} T`}
icon={<Fuel size={14} />}
tone="blue"
onClick={() => onDrill("totalKg")}
/>
<KpiCard
label="累计成本金额"
value={toWan(kpis.totalCost)}
prefix="¥"
unit="万"
left={`我司 ¥${toWan(kpis.companyCost)} 万`}
right={`客户 ¥${toWan(kpis.customerCost)} 万 · 其他 ¥${toWan(kpis.otherCost)} 万`}
icon={<Wallet size={14} />}
tone="blue"
onClick={() => onDrill("totalCost")}
/>
<KpiCard
label="加氢利润"
value={toWan(kpis.customerGrossProfit)}
prefix="¥"
unit="万"
left={`对客 ¥${toWan(kpis.customerRevenue)} 万`}
right={`成本 ¥${toWan(kpis.customerCost)} 万`}
icon={<Activity size={14} />}
tone="green"
onClick={() => onDrill("customerGrossProfit")}
/>
<KpiCard
label="本月加氢"
value={toT(kpis.monthKg)}
unit="T"
left={`加氢费 ¥${toWan(kpis.monthCost)} 万`}
right={`占年比 ${number(kpis.monthShareOfRange, 2)}%`}
icon={<Truck size={14} />}
tone="amber"
onClick={() => onDrill("monthKg")}
/>
<KpiCard
label="本日加氢"
value={number(kpis.todayKg, 2)}
unit="Kg"
left={`加氢费 ${yuan(kpis.todayCost)}`}
right={`占月比 ${number(kpis.todayShareOfMonth, 2)}%`}
icon={<Zap size={14} />}
tone="purple"
onClick={() => onDrill("todayKg")}
/>
</div>
<div className="ehb-insight" aria-label="经营洞察">
<div className="ehb-insight__card">
<div className="ehb-insight__icon is-down">
<TrendingDown size={18} />
</div>
<div>
<div className="ehb-insight__title">月度加氢异常波动</div>
<div className="ehb-insight__value is-neg">
占年 {number(kpis.monthShareOfRange, 2)}%
</div>
<div className="ehb-insight__desc">
当月 {toT(kpis.monthKg)} T ·{" "}
{overview.range.endDate?.slice(0, 7) || "当前月"} 当前统计口径
</div>
</div>
</div>
<button
type="button"
className="ehb-insight__card ehb-insight__card--rank"
onClick={() => onDrill("station")}
>
<span className="ehb-insight__icon">
<Shield size={18} />
</span>
<span className="ehb-insight__rank-body">
<span className="ehb-insight__title">头部加氢站占比</span>
<span className="ehb-insight__value">
Top5 {number(top5Share, 1)}%
</span>
<span className="ehb-insight__desc">
累计 {toT(kpis.totalKg)} T · 点击展开加氢量排名
</span>
</span>
<ChevronDown
size={14}
className="ehb-insight__rank-chevron"
aria-hidden
/>
</button>
<div className="ehb-insight__card">
<div className="ehb-insight__icon is-ok">
<Activity size={18} />
</div>
<div>
<div className="ehb-insight__title">加氢利润率</div>
<div
className={`ehb-insight__value ${profitRate >= 0 ? "is-pos" : "is-neg"}`}
>
{number(profitRate, 2)}%
</div>
<div className="ehb-insight__desc">
加氢利润 ¥{toWan(kpis.customerGrossProfit)} · 对客金额 ¥
{toWan(kpis.customerRevenue)}
</div>
</div>
</div>
</div>
</section>
<section className="ehb-overview-charts">
<div className="ehb-chart-box">
<div className="ehb-chart-box-head">
<div className="ehb-chart-box-title">
{overview.range.startDate?.slice(0, 4) || "当前"} 年月度加氢量
</div>
<div className="ehb-chart-legend-inline">
<span className="ehb-chart-legend-tag">
<i className="ehb-legend-sq is-income" />
内部客户
</span>
<span className="ehb-chart-legend-tag">
<i className="ehb-legend-sq is-cost" />
外部客户
</span>
<span className="ehb-chart-box-meta">
统计范围:{rangeText(overview.range)} · 单位 Kg
</span>
</div>
</div>
<div className="ehb-chart-box-body">
{overview.monthly.length === 0 ? (
<EmptyTable>当前筛选暂无月度趋势数据</EmptyTable>
) : (
<ResponsiveContainer
width="100%"
height="100%"
minWidth={0}
initialDimension={{ width: 900, height: 220 }}
>
<BarChart
data={overview.monthly}
margin={{ top: 6, right: 10, left: 0, bottom: 0 }}
>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke="#e2e8f0"
/>
<XAxis
dataKey="month"
tick={{ fill: "#64748b", fontSize: 11 }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fill: "#94a3b8", fontSize: 10 }}
width={56}
axisLine={false}
tickLine={false}
tickFormatter={(v) => number(Number(v))}
/>
<Tooltip
formatter={(value: unknown, name: unknown) => [
kg(Number(value)),
name === "lingniuKg" ? "内部客户" : "外部客户",
]}
/>
<Legend
formatter={(v) =>
v === "lingniuKg" ? "内部客户" : "外部客户"
}
/>
<Bar
dataKey="lingniuKg"
stackId="kg"
fill="#3b82f6"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="externalKg"
stackId="kg"
fill="#fbbf24"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
</section>
<MonthlyRevenueComparison overview={overview} onDrill={onDrill} />
<OverviewRankings overview={overview} onDrill={onDrill} />
<OverviewTables overview={overview} onDrill={onDrill} />
</>
);
}
function DailyPage({
daily,
onDrill,
}: {
daily: H2BiDailyResponse;
onDrill: (metric: H2BiDrillMetric) => void;
}) {
const kpis = daily.kpis;
return (
<div className="ehb-daily-container">
<section className="ehb-daily-kpi-grid" aria-label="按日经营指标">
<button
type="button"
className="ehb-daily-kpi-card"
onClick={() => onDrill("totalKg")}
>
<div className="ehb-daily-kpi-head">
<span className="ehb-daily-kpi-title">区间加氢量</span>
<span className="ehb-kpi-dual__badge is-blue">
<Fuel size={14} />
</span>
</div>
<div className="ehb-daily-kpi-val">
<span className="ehb-kpi-dual__num">{number(kpis.totalKg, 2)}</span>
<span className="ehb-kpi-dual__unit">Kg</span>
</div>
<div className="ehb-daily-kpi-sub">{rangeText(daily.range)}</div>
</button>
<button
type="button"
className="ehb-daily-kpi-card"
onClick={() => onDrill("totalCost")}
>
<div className="ehb-daily-kpi-head">
<span className="ehb-daily-kpi-title">区间成本</span>
<span className="ehb-kpi-dual__badge is-green">
<Wallet size={14} />
</span>
</div>
<div className="ehb-daily-kpi-val">
<span className="ehb-kpi-dual__num">{yuan(kpis.totalCost)}</span>
</div>
<div className="ehb-daily-kpi-sub">真实成本台账汇总</div>
</button>
<button
type="button"
className="ehb-daily-kpi-card"
onClick={() => onDrill("day")}
>
<div className="ehb-daily-kpi-head">
<span className="ehb-daily-kpi-title">有效天数</span>
<span className="ehb-kpi-dual__badge is-amber">
<TrendingUp size={14} />
</span>
</div>
<div className="ehb-daily-kpi-val">
<span className="ehb-kpi-dual__num">{number(kpis.activeDays)}</span>
</div>
<div className="ehb-daily-kpi-sub">
日均 {number(kpis.averageDailyKg, 2)} Kg
</div>
</button>
<button
type="button"
className="ehb-daily-kpi-card"
onClick={() => onDrill("station")}
>
<div className="ehb-daily-kpi-head">
<span className="ehb-daily-kpi-title">涉及加氢站</span>
<span className="ehb-kpi-dual__badge is-purple">
<Zap size={14} />
</span>
</div>
<div className="ehb-daily-kpi-val">
<span className="ehb-kpi-dual__num">
{number(kpis.stationCount)}
</span>
<span className="ehb-kpi-dual__unit"></span>
</div>
<div className="ehb-daily-kpi-sub">按明细站点去重</div>
</button>
</section>
<section className="ehb-daily-chart-section">
<div className="ehb-daily-chart-head">
<div className="ehb-daily-chart-title">
<span>每日加氢量</span>
<span className="ehb-title-sub">
(点击柱体下锚定位到对应日期明细)
</span>
</div>
<div className="ehb-daily-chart-meta-group">
<div className="ehb-daily-chart-legend">
<span className="ehb-legend-item">
<span className="ehb-legend-dot is-own" />
加氢量
</span>
</div>
<span className="ehb-daily-chart-meta">时间单位:日 · 单位 Kg</span>
</div>
</div>
<div className="ehb-daily-bar-container">
{daily.trend.length === 0 ? (
<EmptyTable>当前日期范围暂无按日数据</EmptyTable>
) : (
<ResponsiveContainer
width="100%"
height="100%"
minWidth={0}
initialDimension={{ width: 900, height: 220 }}
>
<BarChart
data={daily.trend}
margin={{ top: 6, right: 10, left: 0, bottom: 0 }}
>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke="#e2e8f0"
/>
<XAxis
dataKey="date"
tick={{ fill: "#64748b", fontSize: 10 }}
tickFormatter={(value) => String(value).slice(5)}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fill: "#94a3b8", fontSize: 10 }}
width={54}
axisLine={false}
tickLine={false}
tickFormatter={(v) => number(Number(v))}
/>
<Tooltip
formatter={(value: unknown) => [kg(Number(value)), "加氢量"]}
/>
<Bar dataKey="kg" fill="#3b82f6" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
</section>
<section className="ehb-daily-table-card">
<div className="ehb-daily-table-head">
<div className="ehb-daily-table-title">
每日加氢数据明细{" "}
<span className="ehb-title-sub">
(可多层下钻:按日 加氢站 客户 车辆及数据源)
</span>
</div>
</div>
{daily.days.length === 0 ? (
<EmptyTable>当前日期范围暂无按日明细</EmptyTable>
) : (
<table className="ehb-table">
<thead>
<tr>
<th>日期</th>
<th className="is-num">加氢量</th>
<th className="is-num">成本</th>
<th className="is-num">流水笔数</th>
<th className="is-num">站点</th>
</tr>
</thead>
<tbody>
{daily.days.map((row) => (
<tr
key={row.date}
className="is-clickable"
onClick={() => onDrill("day")}
>
<td>
<strong>{row.date}</strong>{" "}
<span className="ehb-kpi-drill-hint">钻取 </span>
</td>
<td className="is-num is-mono">{kg(row.kg)}</td>
<td className="is-num is-mono">{yuan(row.cost)}</td>
<td className="is-num">{number(row.recordCount)}</td>
<td className="is-num">{number(row.stationCount)}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</div>
);
}
function DrillModal({
query,
title,
onClose,
}: {
query: H2BiQuery & { metric: H2BiDrillMetric };
title: string;
onClose: () => void;
}) {
const [search, setSearch] = useState("");
const state = useRemoteData(`drill:${JSON.stringify(query)}`, () =>
fetchH2BiDrill({ ...query, groupBy: "record", page: 1, pageSize: 100 }),
);
const data: H2BiDrillResponse | null = state.data;
const records = useMemo(
() =>
(data?.records || []).filter((record) =>
JSON.stringify(record)
.toLocaleLowerCase()
.includes(search.trim().toLocaleLowerCase()),
),
[data, search],
);
const columns = useMemo(
() =>
[
"time",
"orderNo",
"stationName",
"customerName",
"plateNo",
"vehicleScope",
"source",
"verifyStatus",
"kg",
"unitPrice",
"cost",
"revenue",
].filter((column) =>
records.some((record) => record[column] !== undefined),
),
[records],
);
const fieldLabel: Record<string, string> = {
time: "加氢时间",
orderNo: "订单号",
stationName: "加氢站",
customerName: "客户",
plateNo: "车牌号",
vehicleScope: "车辆归属",
source: "数据源",
verifyStatus: "核对状态",
kg: "加氢量 (Kg)",
unitPrice: "单价 (元/Kg)",
cost: "成本 (元)",
revenue: "收入 (元)",
};
return (
<div
className="ehb-modal-overlay"
role="dialog"
aria-modal="true"
aria-label={`${title}明细`}
>
<section className="ehb-modal-card">
<header className="ehb-modal-head">
<div className="ehb-modal-head__title-group">
<ChevronLeft size={18} />
<div>
<div className="ehb-modal-head__title">{title}明细</div>
<div className="ehb-modal-head__sub">
真实氢费数据下钻 · 跟随当前筛选条件
</div>
</div>
</div>
<div className="ehb-modal-head__actions">
<button
type="button"
className="ehb-modal-close-btn"
onClick={onClose}
aria-label="关闭"
>
<X size={18} />
</button>
</div>
</header>
<div className="ehb-modal-body">
{state.loading ? (
<EmptyTable>正在加载真实明细数据…</EmptyTable>
) : state.error ? (
<EmptyTable>{state.error}</EmptyTable>
) : (
<>
<div className="ehb-modal-meta-bar">
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">筛选范围</span>
<strong className="ehb-modal-meta-val">
{query.startDate && query.endDate
? `${query.startDate}${query.endDate}`
: `${query.year} 年`}
</strong>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">记录数</span>
<strong className="ehb-modal-meta-val">
{number(
typeof data?.summary.recordCount === "number"
? data.summary.recordCount
: 0,
)}
</strong>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">加氢量</span>
<strong className="ehb-modal-meta-val">
{number(
typeof data?.summary.kg === "number"
? data.summary.kg
: 0,
2,
)}{" "}
Kg
</strong>
</div>
</div>
<div className="ehb-modal-filter-row">
<div className="ehb-modal-filter-group">
<span className="ehb-modal-hint-text">
下钻结果使用当前真实数据源,不回填或补齐任何业务数值。
</span>
</div>
<label className="ehb-modal-search-input">
<Search size={14} />
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="搜索明细"
/>
</label>
</div>
<div className="ehb-modal-table-wrap is-v-scroll">
{records.length === 0 ? (
<EmptyTable>暂无匹配的下钻记录</EmptyTable>
) : (
<table className="ehb-modal-table">
<thead>
<tr>
{columns.map((column) => (
<th key={column}>{fieldLabel[column] || column}</th>
))}
</tr>
</thead>
<tbody>
{records.map((record, index) => (
<tr key={String(record.id || record.orderId || index)}>
{columns.map((column) => (
<td key={column}>{formatRecord(record[column])}</td>
))}
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)}
</div>
</section>
</div>
);
}
function formatRecord(value: H2BiDrillRecord[string]) {
if (value === null || value === undefined || value === "") return "—";
if (value === "lingniu") return "羚牛车辆";
if (value === "external") return "外部车辆";
if (value === "verified") return "已核对";
if (value === "unverified") return "未核对";
if (typeof value === "number") return number(value, 2);
return String(value);
}
function boardQuery(query: H2BiQuery) {
return JSON.stringify(query);
}
export default function HydrogenBiV2App() {
const [scope, setScope] = useState<H2BiScope>("global");
const [view, setView] = useState<H2BiView>("overview");
const [year, setYear] = useState(now.getFullYear());
const [stationId, setStationId] = useState<string | number | null>(null);
const [vehicleScope, setVehicleScope] = useState<H2BiVehicleScope>("all");
const [verifyScope, setVerifyScope] = useState<H2BiVerifyScope>("all");
const [startDate, setStartDate] = useState(defaultStartDate);
const [endDate, setEndDate] = useState(defaultEndDate);
const [reload, setReload] = useState(0);
const [drill, setDrill] = useState<{
metric: H2BiDrillMetric;
title: string;
stationId?: string | number | null;
} | null>(null);
const metaState = useRemoteData("meta", fetchH2BiMeta);
const meta: H2BiMetaResponse | null = metaState.data;
useEffect(() => {
if (meta?.years.length && !meta.years.some((item) => item.value === year))
setYear(meta.years[0].value);
}, [meta, year]);
const query = useMemo<H2BiQuery>(
() => ({
year,
startDate: view === "daily" ? startDate : undefined,
endDate: view === "daily" ? endDate : undefined,
stationId: scope === "station" ? stationId : null,
vehicleScope,
verifyScope,
}),
[
year,
startDate,
endDate,
stationId,
scope,
vehicleScope,
verifyScope,
view,
],
);
const overviewState = useRemoteData(
`overview:${boardQuery(query)}:${reload}`,
() => fetchH2BiOverview(query),
);
const dailyState = useRemoteData(`daily:${boardQuery(query)}:${reload}`, () =>
fetchH2BiDaily(query),
);
const activeState = view === "overview" ? overviewState : dailyState;
const activeRange =
view === "overview" ? overviewState.data?.range : dailyState.data?.range;
const stationName = meta?.stations.find(
(station) => station.id === stationId,
)?.name;
const openDrill = (
metric: H2BiDrillMetric,
targetStationId?: string | number | null,
) => {
const titles: Record<H2BiDrillMetric, string> = {
totalKg: "加氢量",
totalCost: "成本",
totalRevenue: "氢费收入",
customerGrossProfit: "客户毛利",
monthKg: "本月加氢",
todayKg: "本日加氢",
station: "加氢站",
customer: "客户账单",
day: "按日加氢",
};
setDrill({ metric, title: titles[metric], stationId: targetStationId });
};
return (
<div className="ehb-shell" data-annotation-id="energy-h2-bi-board">
<div className="ehb-body">
<header className="ehb-chrome">
<div className="ehb-chrome__lead">
<div className="ehb-crumb">羚牛氢能 BI / 氢能</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
marginTop: 2,
flexWrap: "wrap",
}}
>
<h1>氢能经营看板</h1>
{scope === "global" ? (
<span className="ehb-time-range-pill">
📅 统计时间范围:{rangeText(activeRange)}
</span>
) : null}
</div>
</div>
<div className="ehb-chrome__tools">
<div
className="ehb-seg ehb-seg--wrap"
role="tablist"
aria-label="范围"
>
<button
type="button"
role="tab"
className={scope === "global" ? "is-active" : ""}
aria-selected={scope === "global"}
onClick={() => setScope("global")}
>
全局
</button>
<button
type="button"
role="tab"
className={scope === "station" ? "is-active" : ""}
aria-selected={scope === "station"}
onClick={() => setScope("station")}
>
单站
</button>
</div>
{scope === "global" ? (
<div
className="ehb-seg ehb-seg--wrap"
role="tablist"
aria-label="视图"
>
<button
type="button"
role="tab"
className={view === "daily" ? "is-active" : ""}
aria-selected={view === "daily"}
onClick={() => setView("daily")}
>
按日
</button>
<button
type="button"
role="tab"
className={view === "overview" ? "is-active" : ""}
aria-selected={view === "overview"}
onClick={() => setView("overview")}
>
总览
</button>
</div>
) : null}
</div>
</header>
<div className="ehb-related-modules" aria-label="关联模块">
<button
type="button"
className="ehb-related-card"
onClick={() => {
setScope("station");
setView("daily");
}}
>
<span className="ehb-related-card__title">加氢站日报</span>
<span className="ehb-related-card__body">
也可从顶栏「单站」进入;独立页便于外链
</span>
<span className="ehb-related-card__cta">打开独立站日报 </span>
</button>
<button
type="button"
className="ehb-related-card"
onClick={() => {
window.location.hash = "hydrogen/settlement";
}}
>
<span className="ehb-related-card__title">站日现结登记</span>
<span className="ehb-related-card__body">
真实付款台账入口,按站日登记与核对
</span>
<span className="ehb-related-card__cta">去登记 </span>
</button>
</div>
<section
className="ehb-daily-filter-card"
style={{ marginBottom: 12 }}
aria-label="数据筛选"
>
<div className="ehb-daily-filter-row">
<div className="ehb-daily-filter-group">
<PrototypeYearSelect
value={year}
years={(meta?.years ?? []).map((item) => item.value)}
onChange={setYear}
/>
{scope === "station" ? (
<select
className="ehb-modal-select"
value={stationId ?? ""}
onChange={(event) => setStationId(event.target.value || null)}
aria-label="加氢站"
>
<option value="">全部加氢站</option>
{(meta?.stations || []).map((station) => (
<option key={station.id} value={station.id}>
{station.name}
</option>
))}
</select>
) : null}
<div className="ehb-pill-tabs">
<button
type="button"
className={`ehb-pill-btn ${verifyScope === "all" ? "is-active" : ""}`}
onClick={() => setVerifyScope("all")}
>
全量订单
</button>
<button
type="button"
className={`ehb-pill-btn ${verifyScope === "verified" ? "is-active" : ""}`}
onClick={() => setVerifyScope("verified")}
>
仅已核对
</button>
</div>
</div>
<div className="ehb-daily-filter-group">
{view === "daily" ? (
<>
<input
className="ehb-modal-select"
type="date"
value={startDate}
onChange={(event) => setStartDate(event.target.value)}
aria-label="开始日期"
/>
<input
className="ehb-modal-select"
type="date"
value={endDate}
onChange={(event) => setEndDate(event.target.value)}
aria-label="结束日期"
/>
</>
) : null}
<div className="ehb-fleet-segmented">
<button
type="button"
className={`ehb-fleet-btn ${vehicleScope === "all" ? "is-active" : ""}`}
onClick={() => setVehicleScope("all")}
>
全部车辆
</button>
<button
type="button"
className={`ehb-fleet-btn ${vehicleScope === "lingniu" ? "is-active" : ""}`}
onClick={() => setVehicleScope("lingniu")}
>
<Truck size={14} />
仅羚牛车辆
</button>
<button
type="button"
className={`ehb-fleet-btn ${vehicleScope === "external" ? "is-active" : ""}`}
onClick={() => setVehicleScope("external")}
>
<Truck size={14} />
仅外部车辆
</button>
</div>
<span className="ehb-chrome__clock">
{activeState.data?.watermark.ledgerAt || "加载中…"}
</span>
<button
type="button"
className="ehb-btn ehb-btn--ghost"
onClick={() => setReload((value) => value + 1)}
title="数据刷新"
>
<RefreshCw size={14} />
刷新
</button>
{stationName ? (
<span className="ehb-chrome__clock">
当前单站:{stationName}
</span>
) : null}
</div>
</div>
</section>
{metaState.error ? (
<div className="ehb-empty">
<div className="ehb-empty__title">
筛选元数据加载失败:{metaState.error}
</div>
</div>
) : null}
{activeState.loading && !activeState.data ? (
<div className="ehb-empty">
<RefreshCw className="ehb-empty__icon" size={22} />
<div className="ehb-empty__title">正在加载真实氢能数据…</div>
</div>
) : null}
{activeState.error ? (
<div className="ehb-empty">
<div className="ehb-empty__title">{activeState.error}</div>
<button
type="button"
className="ehb-btn"
onClick={() => setReload((value) => value + 1)}
>
重新加载
</button>
</div>
) : null}
{view === "overview" && overviewState.data ? (
<OverviewPage overview={overviewState.data} onDrill={openDrill} />
) : null}
{view === "daily" && dailyState.data ? (
<DailyPage daily={dailyState.data} onDrill={openDrill} />
) : null}
</div>
{drill ? (
<DrillModal
query={{
...query,
metric: drill.metric,
stationId: drill.stationId ?? query.stationId,
}}
title={drill.title}
onClose={() => setDrill(null)}
/>
) : null}
</div>
);
}