1650 lines
53 KiB
TypeScript
1650 lines
53 KiB
TypeScript
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>
|
||
);
|
||
}
|