fix(energy): deliver prototype-aligned hydrogen board and acceptance fixes
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-authored-by: HiFox Agent <agents-noreply@hifox.com>
This commit is contained in:
@@ -28,6 +28,7 @@ export default function ElectricDaily() {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
setMonths(null);
|
||||
const query = pick === 'custom'
|
||||
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
|
||||
: { range: pick };
|
||||
@@ -38,7 +39,11 @@ export default function ElectricDaily() {
|
||||
// 默认展开最新一个月
|
||||
if (m.length > 0) setOpenMonths(new Set([m[0].month]));
|
||||
})
|
||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||
.catch(e => {
|
||||
if (cancelled) return;
|
||||
setMonths(null);
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [customer, pick, effectiveRange.start, effectiveRange.end]);
|
||||
|
||||
@@ -71,6 +76,23 @@ export default function ElectricDaily() {
|
||||
setDateRange(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<DailyRangeControls
|
||||
pick={pick}
|
||||
dateRange={dateRange}
|
||||
customer={customer}
|
||||
onQuickPick={applyQuickPick}
|
||||
onCustomPick={() => setPick('custom')}
|
||||
onDateRangeChange={updateDateRange}
|
||||
onCustomerChange={setCustomer}
|
||||
/>
|
||||
<ErrorState message={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<DailyRangeControls
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { EnergyBiBoardApp } from './hydrogen-bi-v2/PrototypeBoard';
|
||||
import { EnergyBiBoardApp } from '../../vendor/lnbi-8113-exact/prototypes/energy-h2-bi-board/EnergyBiBoardApp';
|
||||
|
||||
/**
|
||||
* New standalone Hydrogen BI surface. The legacy feature files remain for a
|
||||
* controlled rollback, but the live hydrogen route now uses only v2.
|
||||
* The accepted hydrogen fee BI surface. It is shared with the independent
|
||||
* acceptance route so the menu entry and /energy/hydrogen-board cannot drift.
|
||||
*/
|
||||
export default function HydrogenModule() {
|
||||
return <EnergyBiBoardApp />;
|
||||
|
||||
@@ -51,6 +51,19 @@ function resetPageScroll() {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
function onlyStationsWithHydrogenRecords(result: HydrogenStationBoardResponse): HydrogenStationBoardResponse {
|
||||
const stations = result.stations.filter(station => station.recordCount > 0);
|
||||
return {
|
||||
...result,
|
||||
stations,
|
||||
summary: {
|
||||
...result.summary,
|
||||
stationCount: stations.length,
|
||||
activeStationCount: stations.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function HydrogenStationBoard({ embedded = false }: { embedded?: boolean }) {
|
||||
const [dateRange, setDateRange] = useState(defaultRange);
|
||||
const [selectedStationId, setSelectedStationId] = useState<number | null>(null);
|
||||
@@ -60,6 +73,10 @@ export default function HydrogenStationBoard({ embedded = false }: { embedded?:
|
||||
|
||||
const load = useCallback(async (force = false) => {
|
||||
setLoading(true);
|
||||
// A new request invalidates the previous range immediately. Never leave
|
||||
// stale figures visible while the current API request is pending or failed.
|
||||
setData(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await fetchHydrogenStationBoard({
|
||||
startDate: dateRange.start,
|
||||
@@ -67,9 +84,10 @@ export default function HydrogenStationBoard({ embedded = false }: { embedded?:
|
||||
stationId: selectedStationId,
|
||||
force,
|
||||
});
|
||||
setData(result);
|
||||
setData(onlyStationsWithHydrogenRecords(result));
|
||||
setError(null);
|
||||
} catch (reason) {
|
||||
setData(null);
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Calendar, ChevronRight, Fuel, TrendingUp, Truck, Wallet, X, Zap } from "lucide-react";
|
||||
import { fetchH2BiDaily, fetchH2BiDrill, fetchH2BiMeta, fetchH2BiOverview } from "./api";
|
||||
import { downloadExcelAoa } from "./prototype-download";
|
||||
import { finiteNumber, formatNumber as number, formatScaled } from "./display-format";
|
||||
import type { H2BiDailyResponse, H2BiDrillResponse, H2BiMetaResponse, H2BiOverviewResponse, H2BiQuery, H2BiVehicleScope, H2BiVerifyScope } from "./types";
|
||||
import "./energy-operations-board.css";
|
||||
|
||||
const tons = (kg: unknown) => formatScaled(kg, 1000);
|
||||
const wan = (yuan: unknown) => formatScaled(yuan, 10000);
|
||||
const stamp = (value: string | null) => value ? value.replace("T", " ").slice(0, 19) : "—";
|
||||
|
||||
export const monthlyChange = (monthly: H2BiOverviewResponse["monthly"]) => {
|
||||
const valid = monthly
|
||||
.filter((item) => finiteNumber(item.totalKg) !== null)
|
||||
.sort((a, b) => a.month.localeCompare(b.month))
|
||||
.slice(-2);
|
||||
const previous = finiteNumber(valid[0]?.totalKg);
|
||||
const current = finiteNumber(valid[1]?.totalKg);
|
||||
if (valid.length < 2 || previous === null || current === null || previous === 0) return { value: "—", detail: "暂不可用" };
|
||||
const change = (current - previous) / previous * 100;
|
||||
return {
|
||||
value: `${change > 0 ? "+" : ""}${number(change, 1)}%`,
|
||||
detail: `${Number(valid[1].month.slice(-2))}月较${Number(valid[0].month.slice(-2))}月`,
|
||||
};
|
||||
};
|
||||
|
||||
type View = "overview" | "daily";
|
||||
type Scope = "global" | "station";
|
||||
type DrillLevel = "station" | "customer" | "vehicle" | "record";
|
||||
type Drill = { title: string; level: DrillLevel; stationId?: string; stationName?: string; customerId?: number; customerName?: string; plateNo?: string } | null;
|
||||
|
||||
export default function EnergyOperationsBoard() {
|
||||
const [meta, setMeta] = useState<H2BiMetaResponse | null>(null);
|
||||
const [overview, setOverview] = useState<H2BiOverviewResponse | null>(null);
|
||||
const [daily, setDaily] = useState<H2BiDailyResponse | null>(null);
|
||||
const [scope, setScope] = useState<Scope>("global");
|
||||
const [view, setView] = useState<View>("overview");
|
||||
const [year, setYear] = useState(new Date().getFullYear());
|
||||
const [stationId, setStationId] = useState<string>("");
|
||||
const [vehicleScope, setVehicleScope] = useState<H2BiVehicleScope>("all");
|
||||
const [verifyScope, setVerifyScope] = useState<H2BiVerifyScope>("all");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [drill, setDrill] = useState<Drill>(null);
|
||||
const [drillData, setDrillData] = useState<H2BiDrillResponse | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchH2BiMeta().then((result) => {
|
||||
setMeta(result);
|
||||
if (result.years.length && !result.years.some((item) => item.value === year)) setYear(result.years[0].value);
|
||||
}).catch((reason: unknown) => setError(reason instanceof Error ? reason.message : "筛选项加载失败"));
|
||||
}, []);
|
||||
|
||||
const query = useMemo<H2BiQuery>(() => ({
|
||||
year,
|
||||
stationId: scope === "station" && stationId ? stationId : null,
|
||||
vehicleScope,
|
||||
verifyScope,
|
||||
}), [scope, stationId, vehicleScope, verifyScope, year]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
Promise.all([fetchH2BiOverview(query), fetchH2BiDaily(query)])
|
||||
.then(([nextOverview, nextDaily]) => {
|
||||
if (!active) return;
|
||||
setOverview(nextOverview);
|
||||
setDaily(nextDaily);
|
||||
})
|
||||
.catch((reason: unknown) => active && setError(reason instanceof Error ? reason.message : "能源数据加载失败"))
|
||||
.finally(() => active && setLoading(false));
|
||||
return () => { active = false; };
|
||||
}, [query, refreshKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!drill) { setDrillData(null); return; }
|
||||
fetchH2BiDrill({ ...query, stationId: drill.stationId ?? query.stationId, customerId: drill.customerId, plateNo: drill.plateNo, groupBy: drill.level, page: 1, pageSize: 100 })
|
||||
.then(setDrillData)
|
||||
.catch(() => setDrillData(null));
|
||||
}, [drill, query]);
|
||||
|
||||
const kpi = overview?.kpis;
|
||||
const kpis = [
|
||||
{ label: "累计加氢量", value: tons(kpi?.totalKg), unit: "T", icon: Fuel, sub: [["我司承担", `${tons(kpi?.companyBearingKg)} T`], ["客户承担", `${tons(kpi?.customerBearingKg)} T`], ["其他", `${tons(kpi?.otherBearingKg)} T`]], drill: { title: "累计加氢量", level: "station" as const }, featured: true },
|
||||
{ label: "累计加氢费", value: wan(kpi?.totalCost), unit: "万", prefix: "¥", icon: Wallet, sub: [["我司承担", `¥${wan(kpi?.companyCost)} 万`], ["客户承担", `¥${wan(kpi?.customerCost)} 万`], ["其他", `¥${wan(kpi?.otherCost)} 万`]], drill: { title: "累计加氢费", level: "station" as const } },
|
||||
{ label: "加氢利润", value: wan(kpi?.customerGrossProfit), unit: "万", prefix: "¥", icon: TrendingUp, sub: `对客 ¥${wan(kpi?.customerRevenue)}万 · 成本 ¥${wan(kpi?.customerCost)}万`, drill: { title: "加氢利润", level: "station" as const } },
|
||||
{ label: "本月加氢量", value: tons(kpi?.monthKg), unit: "T", icon: Truck, sub: `加氢费 ¥${wan(kpi?.monthCost)} 万 · 占累计 ${number(kpi?.monthShareOfRange)}%`, drill: { title: "本月加氢量", level: "station" as const }, featured: true },
|
||||
{ label: "今日加氢量", value: number(kpi?.todayKg), unit: "Kg", icon: Zap, sub: `加氢费 ¥${number(kpi?.todayCost)} · 占本月 ${number(kpi?.todayShareOfMonth)}%`, drill: { title: "今日加氢量", level: "station" as const } },
|
||||
];
|
||||
const displayMonthly = useMemo(() => {
|
||||
const source = new Map((overview?.monthly ?? []).map((item) => [item.month, item]));
|
||||
const finalMonth = overview?.range.endDate?.startsWith(String(year))
|
||||
? Number(overview.range.endDate.slice(5, 7))
|
||||
: 12;
|
||||
return Array.from({ length: Math.max(finalMonth, 1) }, (_, index) => {
|
||||
const month = `${year}-${String(index + 1).padStart(2, "0")}`;
|
||||
const item = source.get(month);
|
||||
return {
|
||||
month,
|
||||
totalKg: finiteNumber(item?.totalKg) ?? 0,
|
||||
lingniuKg: finiteNumber(item?.lingniuKg) ?? 0,
|
||||
externalKg: finiteNumber(item?.externalKg) ?? 0,
|
||||
customerRevenue: finiteNumber(item?.customerRevenue) ?? 0,
|
||||
cost: finiteNumber(item?.cost) ?? 0,
|
||||
};
|
||||
});
|
||||
}, [overview, year]);
|
||||
const maxMonth = Math.max(...displayMonthly.map((item) => item.totalKg), 1);
|
||||
const maxDay = Math.max(...(daily?.trend.map((item) => finiteNumber(item.kg) ?? 0) ?? []), 1);
|
||||
const topTotal = overview?.stations.reduce((sum, item) => sum + (finiteNumber(item.kg) ?? 0), 0) ?? 0;
|
||||
const topFive = overview?.stations.slice().sort((a, b) => b.kg - a.kg).slice(0, 5) ?? [];
|
||||
const topShare = topTotal ? number(topFive.reduce((sum, item) => sum + item.kg, 0) / topTotal * 100, 1) : "—";
|
||||
const safeTotalKg = finiteNumber(kpi?.totalKg);
|
||||
const safeProfit = finiteNumber(kpi?.customerGrossProfit);
|
||||
const unitProfit = safeTotalKg && safeProfit !== null ? number(safeProfit / safeTotalKg) : "—";
|
||||
const maxFinance = Math.max(...displayMonthly.flatMap((item) => [item.customerRevenue, item.cost]), 1);
|
||||
const regionTotal = overview?.regions.reduce((sum, item) => sum + (finiteNumber(item.kg) ?? 0), 0) ?? 0;
|
||||
const bearerTotal = finiteNumber(kpi?.totalKg) ?? 0;
|
||||
const bearerPct = (value: unknown) => bearerTotal ? (finiteNumber(value) ?? 0) / bearerTotal * 100 : 0;
|
||||
const monthChange = monthlyChange(overview?.monthly ?? []);
|
||||
|
||||
const exportOverview = () => overview && downloadExcelAoa([
|
||||
["加氢站", "加氢量(Kg)", "成本(元)", "对客金额(元)", "流水笔数"],
|
||||
...overview.stations.map((row) => [row.name, row.kg, row.cost, row.customerRevenue, row.recordCount]),
|
||||
], `氢能经营看板_${year}.xlsx`, "氢能经营看板");
|
||||
|
||||
return (
|
||||
<main className="eob" data-component="energy-operations-board-v1">
|
||||
<header className="eob-hero">
|
||||
<div className="eob-brand"><span><Fuel size={21} /></span><div><div className="eob-title-line"><h1>氢能经营看板</h1><b>实时运营</b></div><p>统计时间范围:{overview?.range.startDate ?? "—"} 至 {overview?.range.endDate ?? "—"}</p></div></div>
|
||||
<div className="eob-hero-nav">
|
||||
<div className="eob-scope" aria-label="看板范围">
|
||||
<button className={scope === "global" ? "is-active" : ""} onClick={() => setScope("global")}>全局网络</button>
|
||||
<button className={scope === "station" ? "is-active" : ""} onClick={() => setScope("station")}>单站视角</button>
|
||||
</div>
|
||||
<div className="eob-tabs"><button className={view === "overview" ? "is-active" : ""} onClick={() => setView("overview")}>经营总览</button><button className={view === "daily" ? "is-active" : ""} onClick={() => setView("daily")}>日期</button></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="eob-filters" aria-label="筛选条件">
|
||||
<div className="eob-filter-left">
|
||||
<label className="eob-year"><Calendar size={14}/><select aria-label="年份" value={year} onChange={(e) => setYear(Number(e.target.value))}>{(meta?.years ?? []).map((item) => <option value={item.value} key={item.value}>{item.value} 年度</option>)}</select></label>
|
||||
<div className="eob-vehicle-tabs" aria-label="车辆范围"><button className={vehicleScope === "all" ? "is-active" : ""} onClick={() => setVehicleScope("all")}>全部车辆</button><button className={vehicleScope === "lingniu" ? "is-active" : ""} onClick={() => setVehicleScope("lingniu")}><i/>羚牛车辆</button><button className={vehicleScope === "external" ? "is-active" : ""} onClick={() => setVehicleScope("external")}><i/>外部车辆</button></div>
|
||||
{scope === "station" && <label>站点<select value={stationId} onChange={(e) => setStationId(e.target.value)}><option value="">请选择站点</option>{(meta?.stations ?? []).map((item) => <option value={String(item.id)} key={String(item.id)}>{item.name}</option>)}</select></label>}
|
||||
</div>
|
||||
<div className="eob-filter-right">
|
||||
<select aria-label="核对状态" value={verifyScope} onChange={(e) => setVerifyScope(e.target.value as H2BiVerifyScope)}><option value="all">全量订单</option><option value="verified">仅已核对</option></select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error && <div className="eob-state is-error">{error}<button onClick={() => setRefreshKey((value) => value + 1)}>重新加载</button></div>}
|
||||
{loading && <div className="eob-state">正在加载真实能源数据…</div>}
|
||||
|
||||
{!error && !loading && view === "overview" && <>
|
||||
<section className="eob-mobile-overview" aria-label="累计经营概览"><header><h2>累计经营概览</h2><span>{year} 年累计</span></header><div className="eob-mobile-totals"><button onClick={() => setDrill({ title: "累计加氢量", level: "station" })}><span>累计加氢量</span><strong>{tons(kpi?.totalKg)}<small>T</small></strong></button><button onClick={() => setDrill({ title: "累计成本金额", level: "station" })}><span>累计成本金额</span><strong>¥{wan(kpi?.totalCost)}<small>万</small></strong></button></div><div className="eob-bearer-bar"><i style={{width:`${bearerPct(kpi?.companyBearingKg)}%`}}/><i style={{width:`${bearerPct(kpi?.customerBearingKg)}%`}}/><i style={{width:`${bearerPct(kpi?.otherBearingKg)}%`}}/></div><div className="eob-bearers"><span>我司<strong>{tons(kpi?.companyBearingKg)}T</strong><small>{number(bearerPct(kpi?.companyBearingKg),1)}%</small></span><span>客户<strong>{tons(kpi?.customerBearingKg)}T</strong><small>{number(bearerPct(kpi?.customerBearingKg),1)}%</small></span><span>其他<strong>{tons(kpi?.otherBearingKg)}T</strong><small>{number(bearerPct(kpi?.otherBearingKg),1)}%</small></span></div></section>
|
||||
<button className="eob-mobile-profit" onClick={() => setDrill({ title: "加氢利润", level: "station" })}><span><TrendingUp size={22}/></span><div><small>加氢利润</small><strong>¥{wan(kpi?.customerGrossProfit)}<i>万</i></strong></div><dl><div><dt>收入</dt><dd>¥{wan(kpi?.customerRevenue)}万</dd></div><div><dt>成本</dt><dd>¥{wan(kpi?.customerCost)}万</dd></div></dl></button>
|
||||
<section className="eob-mobile-period"><button onClick={() => setDrill({ title: "本月加氢", level: "station" })}><span>本月加氢</span><strong>{tons(kpi?.monthKg)}<small>T</small></strong><p>费用 ¥{wan(kpi?.monthCost)}万</p></button><button onClick={() => setDrill({ title: "本日加氢", level: "station" })}><span>本日加氢</span><strong>{number(kpi?.todayKg)}<small>Kg</small></strong><p>费用 ¥{number(kpi?.todayCost)}</p></button></section>
|
||||
<section className="eob-kpis" aria-label="五项经营指标">{kpis.map(({ icon: Icon, ...item }) => <button key={item.label} className={`eob-kpi ${item.featured ? "is-featured" : ""}`} onClick={() => setDrill(item.drill)}><span className="eob-kpi-head"><span>{item.label}<small>⌕ 查看明细</small></span><i><Icon size={17} /></i></span><strong>{item.value === "—" ? <b>—</b> : <>{item.prefix}<b>{item.value}</b><small>{item.unit}</small></>}</strong>{Array.isArray(item.sub) ? <div className="eob-kpi-breakdown">{item.sub.map(([label,value])=><span key={label}><small>{label}</small><b>{value}</b></span>)}</div> : <p>{item.sub}</p>}</button>)}</section>
|
||||
<section className="eob-diagnosis-desktop" aria-label="经营诊断"><b>经营诊断</b><article><span>月度环比</span><strong>{monthChange.value}</strong><small>{monthChange.detail}</small></article><article><span>单公斤毛利</span><strong>{unitProfit === "—" ? "—" : `¥${unitProfit}/kg`}</strong><small>{unitProfit === "—" ? "暂不可用" : "按累计加氢量计算"}</small></article><article><span>头部站点占比</span><strong>{topShare === "—" ? "—" : `${topShare}%`}</strong><small>{topShare === "—" ? "暂不可用" : "前5站占总量"}</small></article><article><span>待核对订单</span><strong>—</strong><small>暂不可用</small></article></section>
|
||||
<details className="eob-diagnosis" open={false}><summary>经营诊断 <span>展开查看</span></summary><div><article><span>月度环比</span><strong>{monthChange.value}</strong><small>{monthChange.detail}</small></article><article><span>单公斤毛利</span><strong>{unitProfit === "—" ? "—" : `¥${unitProfit}/kg`}</strong><small>{unitProfit === "—" ? "暂不可用" : "按累计加氢量计算"}</small></article><article><span>头部站点占比</span><strong>{topShare === "—" ? "—" : `${topShare}%`}</strong><small>{topShare === "—" ? "暂不可用" : "前5站占总量"}</small></article><article><span>待核对订单</span><strong>—</strong><small>暂不可用</small></article></div></details>
|
||||
<section className="eob-charts">
|
||||
<article className="eob-panel"><header><h2>{year} 年月度加氢量</h2><span><i className="is-blue" />羚牛车辆 <i className="is-light-blue" />外部车辆 统计范围:{overview?.range.startDate} 至 {overview?.range.endDate} · 单位 Kg</span></header><div className="eob-bars">{displayMonthly.map((item) => <div key={item.month}><span>{number(item.totalKg / 1000, 1)}k</span><b style={{ height: `${item.totalKg ? Math.max(item.totalKg / maxMonth * 100, 2) : 0}%` }}><i className="is-light-blue" style={{ height: `${item.totalKg ? item.externalKg / item.totalKg * 100 : 0}%` }} /><i className="is-blue" /></b><small>{Number(item.month.slice(-2))}月</small></div>)}</div></article>
|
||||
<article className="eob-panel eob-finance"><header><h2>{year} 年月度收支对比</h2><span><i className="is-cyan" />客户收入 <i className="is-purple" />成本支出 统计范围:{overview?.range.startDate} 至 {overview?.range.endDate} · 单位 元</span></header><div className="eob-finance-bars">{displayMonthly.map((item) => <div key={item.month}><span><i className="is-purple" style={{height:`${item.cost ? Math.max(item.cost/maxFinance*100,2) : 0}%`}}/><i className="is-cyan" style={{height:`${item.customerRevenue ? Math.max(item.customerRevenue/maxFinance*100,2) : 0}%`}}/></span><small>{Number(item.month.slice(-2))}月</small></div>)}</div></article>
|
||||
<article className="eob-panel"><header><h2>加氢站加氢量 Top5</h2><button onClick={() => setDrill({ title: "加氢站排名", level: "station" })}>查看明细</button></header><ol className="eob-ranking">{topFive.map((item, index) => <li key={String(item.id)}><b>{index + 1}</b><span>{item.name}</span><i><em style={{ width: `${finiteNumber(topFive[0]?.kg) ? (finiteNumber(item.kg) ?? 0) / (finiteNumber(topFive[0]?.kg) ?? 1) * 100 : 0}%` }} /></i><strong>{number(item.kg, 0)}</strong></li>)}</ol></article>
|
||||
<article className="eob-panel eob-regions"><header><h2>各区域加氢占比</h2><strong>合计 {tons(regionTotal)} T</strong></header><div>{overview?.regions.map((item,index)=><article key={item.region}><b>{index+1}</b><span>{item.region || "未归属"}</span><i><em style={{width:`${regionTotal ? (finiteNumber(item.kg) ?? 0)/regionTotal*100 : 0}%`}}/></i><strong>{number(finiteNumber(item.share) ?? (regionTotal ? (finiteNumber(item.kg) ?? 0)/regionTotal*100 : null),1)}%</strong></article>)}{!overview?.regions.length&&<p>暂无区域数据</p>}</div></article>
|
||||
</section>
|
||||
</>}
|
||||
|
||||
{!error && !loading && view === "daily" && <section className="eob-panel eob-daily"><header><h2>每日加氢趋势</h2><span>{daily?.range.startDate ?? "—"} 至 {daily?.range.endDate ?? "—"}</span></header><div className="eob-bars">{daily?.trend.map((item) => <div key={item.date}><span>{number(item.kg, 0)}</span><b style={{ height: `${Math.max((finiteNumber(item.kg) ?? 0) / maxDay * 100, 2)}%` }}><i className="is-blue" /></b><small>{item.date.slice(5)}</small></div>)}</div><div className="eob-daily-table">{daily?.days.map((item) => <button key={item.date} onClick={() => setDrill({ title: `${item.date} 明细`, level: "station" })}><span>{item.date}</span><strong>{number(item.kg)} Kg</strong><small>¥{number(item.cost)} · {number(item.recordCount, 0)} 笔</small></button>)}</div></section>}
|
||||
|
||||
{drill && <div className="eob-modal" role="dialog" aria-modal="true" data-drill-level={drill.level}><section><header><div><h2>{drill.title}</h2><p>站点 → 客户 → 车辆 → 订单</p></div><button onClick={() => setDrill(null)} aria-label="关闭"><X /></button></header><nav className="eob-drill-crumbs"><button onClick={() => setDrill({ title: drill.title, level: "station" })}>站点</button>{drill.stationName && <><ChevronRight size={14}/><button onClick={() => setDrill({ ...drill, level: "customer", customerId: undefined, customerName: undefined, plateNo: undefined })}>{drill.stationName}</button></>}{drill.customerName && <><ChevronRight size={14}/><button onClick={() => setDrill({ ...drill, level: "vehicle", plateNo: undefined })}>{drill.customerName}</button></>}{drill.plateNo && <><ChevronRight size={14}/><span>{drill.plateNo}</span></>}</nav><div className="eob-modal-body">{!drillData ? <div className="eob-state">正在加载明细…</div> : drill.level === "record" ? <table><thead><tr><th>订单</th><th>加氢量</th><th>成本</th><th>状态</th></tr></thead><tbody>{drillData.records.map((row, index) => <tr key={String(row.id ?? index)}><td>{String(row.orderNo ?? row.id ?? "—")}</td><td>{number(row.kg)} Kg</td><td>¥{number(row.cost)}</td><td>{String(row.verifyStatus ?? row.status ?? "—")}</td></tr>)}{drillData.records.length === 0 && <tr><td colSpan={4}>暂无可用订单</td></tr>}</tbody></table> : <table><thead><tr><th>{drill.level === "station" ? "站点" : drill.level === "customer" ? "客户" : "车辆"}</th><th>加氢量</th><th>成本</th><th>流水</th></tr></thead><tbody>{drillData.groups.map((row) => <tr className="eob-drill-row" key={row.id} onClick={() => setDrill(drill.level === "station" ? { ...drill, level: "customer", stationId: row.id, stationName: row.name } : drill.level === "customer" ? { ...drill, level: "vehicle", customerId: Number(row.id), customerName: row.name } : { ...drill, level: "record", plateNo: row.name })}><td>{row.name}<ChevronRight size={15}/></td><td>{number(row.kg)} Kg</td><td>¥{number(row.cost)}</td><td>{number(row.recordCount, 0)}</td></tr>)}{drillData.groups.length === 0 && <tr><td colSpan={4}>暂无可用数据</td></tr>}</tbody></table>}</div></section></div>}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
import { PrototypeRealDailyView } from "./prototype-real-daily";
|
||||
import type { H2BiOverviewResponse, H2BiQuery } from "./types";
|
||||
import "./prototype-source/styles/energy-bi-board.css";
|
||||
import "./drill-prototype-parity.css";
|
||||
|
||||
const StationDailyApp = (props: { embedded?: boolean }) => (
|
||||
<HydrogenStationBoard embedded={props.embedded} />
|
||||
@@ -844,7 +845,9 @@ function OverviewTrendsDashboard({
|
||||
const [stationListExpanded, setStationListExpanded] = useState(false);
|
||||
const [customerListExpanded, setCustomerListExpanded] = useState(false);
|
||||
|
||||
const stationSummaryList = liveOverview.stations.map((station, index) => ({
|
||||
const stationSummaryList = liveOverview.stations
|
||||
.filter((station) => Number(station.kg) > 0)
|
||||
.map((station, index) => ({
|
||||
rank: index + 1,
|
||||
id: station.id,
|
||||
name: station.name,
|
||||
@@ -857,7 +860,7 @@ function OverviewTrendsDashboard({
|
||||
liveOverview.kpi.customerRevenue > 0
|
||||
? (station.customerRevenue / liveOverview.kpi.customerRevenue) * 100
|
||||
: 0,
|
||||
}));
|
||||
}));
|
||||
const customerSummaryList = liveOverview.customers.map((customer, index) => ({
|
||||
rank: index + 1,
|
||||
id: customer.id,
|
||||
@@ -2078,7 +2081,7 @@ export const EnergyBiBoardApp: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="ehb-shell ehb-shell--embedded"
|
||||
className="ehb-shell"
|
||||
data-annotation-id="energy-h2-bi-board"
|
||||
>
|
||||
<aside className="ehb-rail" aria-label="能源BI模块">
|
||||
@@ -2328,6 +2331,23 @@ export const EnergyBiBoardApp: React.FC = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{liveOverviewError ? (
|
||||
<div className="ehb-live-data-state is-error" role="alert">
|
||||
<strong>数据接口暂不可用</strong>
|
||||
<span>本页未展示任何业务数据,请检查后端服务后重试。</span>
|
||||
<button type="button" className="ehb-btn ehb-btn--outline" onClick={handleRefreshData}>
|
||||
<RefreshCw size={14} aria-hidden />
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
) : !liveOverview ? (
|
||||
<div className="ehb-live-data-state is-loading" role="status" aria-live="polite">
|
||||
<span className="ehb-live-data-spinner" aria-hidden />
|
||||
<strong>正在读取真实氢能数据</strong>
|
||||
<span>加载完成前不展示业务数值</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<section className="ehb-host" aria-label="经营总览">
|
||||
<div className="ehb-host-kpi">
|
||||
<HostKpi
|
||||
@@ -2516,35 +2536,24 @@ export const EnergyBiBoardApp: React.FC = () => {
|
||||
</section>
|
||||
|
||||
{/* 经营趋势图表大盘:月度加氢量、月度收支对比、Top5站加氢量、各区域加氢占比 */}
|
||||
{liveOverviewError ? (
|
||||
<div className="ehb-empty">
|
||||
<div className="ehb-empty__title">{liveOverviewError}</div>
|
||||
</div>
|
||||
) : null}
|
||||
{liveOverview ? (
|
||||
<OverviewTrendsDashboard
|
||||
year={year}
|
||||
fleetScope={fleetScope}
|
||||
verifyScope={verifyScope}
|
||||
liveOverview={liveOverview}
|
||||
onOpenDrill={(lbl) => setKpiDrillType(lbl)}
|
||||
onOpenCustomerBill={(custName) =>
|
||||
setSelectedBillCustomer(custName)
|
||||
}
|
||||
onOpenStationBill={(stName, prov, id) =>
|
||||
setSelectedStationForDrill({
|
||||
id,
|
||||
name: stName,
|
||||
province: prov,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="ehb-empty">
|
||||
<div className="ehb-empty__title">
|
||||
正在加载真实氢能数据…
|
||||
</div>
|
||||
</div>
|
||||
<OverviewTrendsDashboard
|
||||
year={year}
|
||||
fleetScope={fleetScope}
|
||||
verifyScope={verifyScope}
|
||||
liveOverview={liveOverview}
|
||||
onOpenDrill={(lbl) => setKpiDrillType(lbl)}
|
||||
onOpenCustomerBill={(custName) =>
|
||||
setSelectedBillCustomer(custName)
|
||||
}
|
||||
onOpenStationBill={(stName, prov, id) =>
|
||||
setSelectedStationForDrill({
|
||||
id,
|
||||
name: stName,
|
||||
province: prov,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -34,7 +34,53 @@ export function fetchH2BiOverview(query: H2BiQuery) {
|
||||
}
|
||||
|
||||
export function fetchH2BiDaily(query: H2BiQuery) {
|
||||
return request<H2BiDailyResponse>('daily', query);
|
||||
return request<H2BiDailyResponse>('daily', query).catch(async () => {
|
||||
// The date-group drill is backed by the same read-only ledger and has a
|
||||
// simpler query plan. Keep the date view usable when the aggregate daily
|
||||
// endpoint times out/fails, without substituting mock data.
|
||||
const drill = await request<H2BiDrillResponse>('drill', {
|
||||
...query,
|
||||
groupBy: 'date',
|
||||
pageSize: 400,
|
||||
});
|
||||
const startDate = query.startDate ?? `${query.year}-01-01`;
|
||||
const endDate = query.endDate ?? new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(new Date());
|
||||
const byDate = new Map(drill.groups.map((row) => [row.name, row]));
|
||||
const days = [] as H2BiDailyResponse['trend'];
|
||||
for (let cursor = new Date(`${startDate}T00:00:00Z`); cursor <= new Date(`${endDate}T00:00:00Z`); cursor.setUTCDate(cursor.getUTCDate() + 1)) {
|
||||
const date = cursor.toISOString().slice(0, 10);
|
||||
const row = byDate.get(date);
|
||||
days.push({
|
||||
date,
|
||||
kg: Number(row?.kg ?? 0),
|
||||
lingniuKg: Number(row?.lingniuKg ?? 0),
|
||||
externalKg: Number(row?.externalKg ?? 0),
|
||||
cost: Number(row?.cost ?? 0),
|
||||
recordCount: Number(row?.recordCount ?? 0),
|
||||
});
|
||||
}
|
||||
const totalKg = days.reduce((sum, row) => sum + row.kg, 0);
|
||||
const totalCost = days.reduce((sum, row) => sum + row.cost, 0);
|
||||
return {
|
||||
range: { startDate, endDate },
|
||||
watermark: { ledgerAt: null, paymentAt: null },
|
||||
filters: query,
|
||||
kpis: {
|
||||
totalKg,
|
||||
totalCost,
|
||||
averageDailyKg: totalKg / Math.max(1, days.length),
|
||||
stationCount: Number(drill.summary.stationCount ?? 0),
|
||||
activeDays: days.filter((row) => row.kg > 0).length,
|
||||
},
|
||||
trend: days,
|
||||
days: [...days].reverse(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchH2BiDailyTree(date: string, query: H2BiDailyTreeQuery) {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { bearingLabels } from "./bearing-labels";
|
||||
|
||||
test("records display their ledger bearing type", () => {
|
||||
assert.deepEqual(bearingLabels({ settlementType: 1 }).map(x => x.label), ["客户承担"]);
|
||||
assert.deepEqual(bearingLabels({ settlementType: "2" }).map(x => x.label), ["我司承担"]);
|
||||
assert.deepEqual(bearingLabels({ settlementType: 3 }).map(x => x.label), ["客户自行结算"]);
|
||||
});
|
||||
test("groups display every distinct bearing type, including unknown", () => {
|
||||
assert.deepEqual(bearingLabels({ settlementTypes: "1,2,3,unknown,1" }).map(x => x.label),
|
||||
["客户承担", "我司承担", "客户自行结算", "未明确"]);
|
||||
});
|
||||
test("missing and unrecognized values do not imply an actual payer", () => {
|
||||
for (const settlementType of [null, undefined, "", 4, "all"]) {
|
||||
assert.deepEqual(bearingLabels({ settlementType }).map(x => x.label), ["未明确"]);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
const labels: Record<string, { label: string; className: string }> = {
|
||||
"1": { label: "客户承担", className: "is-cust" },
|
||||
"2": { label: "我司承担", className: "is-lingniu" },
|
||||
"3": { label: "客户自行结算", className: "is-other" },
|
||||
};
|
||||
|
||||
// Use ledger settlement types, never the selected filter or monetary amounts.
|
||||
export function bearingLabels(row: { settlementTypes?: unknown; settlementType?: unknown }) {
|
||||
const types = String(row.settlementTypes ?? row.settlementType ?? "")
|
||||
.split(",").map((value) => value.trim());
|
||||
const results = types.map((value) => labels[value] ?? { label: "未明确", className: "is-other" });
|
||||
return [...new Map(results.map((item) => [item.label, item])).values()];
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react";
|
||||
|
||||
export function DailyTreeButton({ open, label, children, onClick }: {
|
||||
open: boolean; label: string; children: ReactNode; onClick: () => void;
|
||||
}) {
|
||||
const Icon = open ? ChevronDown : ChevronRight;
|
||||
return <button type="button" className="ehb-daily-disclosure" aria-expanded={open}
|
||||
aria-label={`${open ? "收起" : "展开"}${label}`} onClick={onClick}>
|
||||
<Icon size={15} aria-hidden="true" /><span>{children}</span>
|
||||
</button>;
|
||||
}
|
||||
|
||||
export function DailyBranchState({ columns, error, empty, onRetry }: {
|
||||
columns: number; error?: string; empty?: boolean; onRetry: () => void;
|
||||
}) {
|
||||
return <tr className="ehb-daily-branch-state"><td colSpan={columns}>
|
||||
<div role={error ? "alert" : "status"}>
|
||||
{error || (empty ? "当前范围暂无明细" : "正在加载明细…")}
|
||||
{error ? <button type="button" onClick={onRetry}><RefreshCw size={14} aria-hidden="true" />重试</button> : null}
|
||||
</div>
|
||||
</td></tr>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { H2BiDailyResponse } from "./types";
|
||||
|
||||
export function formatDailyChange(value: unknown): string {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return "环比 —";
|
||||
return `环比 ${value > 0 ? "+" : ""}${value.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
/** Export the entire selected range, independently of which branches were opened. */
|
||||
export function dailySummaryRows(daily: H2BiDailyResponse): Array<Array<string | number>> {
|
||||
return [
|
||||
["日期", "加氢站数", "加氢量(Kg)", "成本(元)"],
|
||||
["区间合计", daily.kpis.stationCount, daily.kpis.totalKg, daily.kpis.totalCost],
|
||||
...daily.days.map(day => [day.date, day.stationCount ?? "—", day.kg, day.cost]),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { DailyBranchState, DailyTreeButton } from "./daily-detail-controls";
|
||||
import { dailySummaryRows, formatDailyChange } from "./daily-detail-format";
|
||||
import type { H2BiDailyResponse } from "./types";
|
||||
|
||||
test("每日环比区分真实零值、缺失值和非数值", () => {
|
||||
for (const value of [null, undefined, NaN, Infinity, "0"]) assert.equal(formatDailyChange(value), "环比 —");
|
||||
assert.equal(formatDailyChange(0), "环比 0.00%");
|
||||
assert.equal(formatDailyChange(-12.5), "环比 -12.50%");
|
||||
assert.equal(formatDailyChange(12.5), "环比 +12.50%");
|
||||
});
|
||||
|
||||
test("日期汇总导出包含全部日期,不依赖已展开明细并保留真零", () => {
|
||||
const daily = {
|
||||
kpis: { stationCount: 2, totalKg: 15.25, totalCost: 450 },
|
||||
days: [
|
||||
{ date: "2026-09-03", stationCount: 2, kg: 15.25, cost: 450 },
|
||||
{ date: "2026-09-02", stationCount: 0, kg: 0, cost: 0 },
|
||||
],
|
||||
} as H2BiDailyResponse;
|
||||
const rows = dailySummaryRows(daily);
|
||||
assert.equal(rows.length, 4);
|
||||
assert.deepEqual(rows[1], ["区间合计", 2, 15.25, 450]);
|
||||
assert.deepEqual(rows[3], ["2026-09-02", 0, 0, 0]);
|
||||
});
|
||||
|
||||
test("层级按钮包含键盘原生语义与明确展开状态", () => {
|
||||
for (const open of [false, true]) {
|
||||
const html = renderToStaticMarkup(createElement(DailyTreeButton, {
|
||||
open, label: "测试站客户明细", children: "测试站", onClick() {},
|
||||
}));
|
||||
assert.match(html, /<button type="button"/);
|
||||
assert.ok(html.includes(`aria-expanded="${open}"`));
|
||||
assert.ok(html.includes(`${open ? "收起" : "展开"}测试站客户明细`));
|
||||
}
|
||||
});
|
||||
|
||||
test("展开状态区分加载、空数据、失败及重试入口", () => {
|
||||
const render = (props: Partial<Parameters<typeof DailyBranchState>[0]>) => renderToStaticMarkup(createElement(DailyBranchState, {
|
||||
columns: 3, onRetry() {}, ...props,
|
||||
}));
|
||||
assert.match(render({}), /正在加载明细/);
|
||||
assert.match(render({ empty: true }), /当前范围暂无明细/);
|
||||
const error = render({ error: "站点明细加载失败,请重试" });
|
||||
assert.match(error, /role="alert"/);
|
||||
assert.match(error, /<button type="button"/);
|
||||
assert.match(error, /colSpan="3"/i);
|
||||
assert.doesNotMatch(error, /正在加载明细/);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { devMockResponse } from "./dev-mock-api";
|
||||
|
||||
test("开发 mock 覆盖健康检查与氢能 v2 只读接口", () => {
|
||||
for (const path of [
|
||||
"/api/health",
|
||||
"/api/energy/h2/v2/meta",
|
||||
"/api/energy/h2/v2/overview",
|
||||
"/api/energy/h2/v2/daily",
|
||||
"/api/energy/h2/v2/daily-tree",
|
||||
"/api/energy/h2/v2/drill",
|
||||
]) assert.ok(devMockResponse(path), path);
|
||||
|
||||
assert.equal(devMockResponse("/api/unknown"), undefined);
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { Plugin } from "vite";
|
||||
|
||||
const station = {
|
||||
id: "101",
|
||||
name: "本地验收加氢站",
|
||||
province: "浙江省",
|
||||
city: "嘉兴市",
|
||||
kg: 12345.67,
|
||||
lingniuKg: 10000,
|
||||
externalKg: 2345.67,
|
||||
cost: 23456.78,
|
||||
revenue: 34567.89,
|
||||
customerRevenue: 34567.89,
|
||||
customerCost: 18000,
|
||||
companyCost: 4000,
|
||||
otherCost: 1456.78,
|
||||
recordCount: 42,
|
||||
customerCount: 1,
|
||||
share: 100,
|
||||
};
|
||||
|
||||
const customer = {
|
||||
id: 1,
|
||||
name: "本地验收客户",
|
||||
kg: 12345.67,
|
||||
customerBearingKg: 10000,
|
||||
companyBearingKg: 2000,
|
||||
otherBearingKg: 345.67,
|
||||
bearer: "both",
|
||||
cost: 23456.78,
|
||||
revenue: 34567.89,
|
||||
customerRevenue: 34567.89,
|
||||
customerCost: 18000,
|
||||
companyCost: 4000,
|
||||
otherCost: 1456.78,
|
||||
recordCount: 42,
|
||||
};
|
||||
|
||||
const range = { startDate: "2026-01-01", endDate: "2026-08-31" };
|
||||
const watermark = { ledgerAt: "2026-08-31 16:00:00", paymentAt: null };
|
||||
const kpis = {
|
||||
totalKg: 12345.67,
|
||||
totalCost: 23456.78,
|
||||
customerBearingKg: 10000,
|
||||
companyBearingKg: 2000,
|
||||
otherBearingKg: 345.67,
|
||||
customerRevenue: 34567.89,
|
||||
customerCost: 18000,
|
||||
companyCost: 4000,
|
||||
otherCost: 1456.78,
|
||||
totalRevenue: 34567.89,
|
||||
customerGrossProfit: 16567.89,
|
||||
monthKg: 3456.78,
|
||||
monthCost: 6789.01,
|
||||
todayKg: 123.45,
|
||||
todayCost: 234.56,
|
||||
monthShareOfRange: 28,
|
||||
todayShareOfMonth: 3.57,
|
||||
recordCount: 42,
|
||||
stationCount: 1,
|
||||
};
|
||||
|
||||
const overview = {
|
||||
range,
|
||||
watermark,
|
||||
filters: {},
|
||||
kpis,
|
||||
monthly: [{
|
||||
month: "2026-08",
|
||||
totalKg: 12345.67,
|
||||
lingniuKg: 10000,
|
||||
externalKg: 2345.67,
|
||||
cost: 23456.78,
|
||||
customerCost: 18000,
|
||||
companyCost: 4000,
|
||||
otherCost: 1456.78,
|
||||
revenue: 34567.89,
|
||||
customerRevenue: 34567.89,
|
||||
customerGrossProfit: 16567.89,
|
||||
}],
|
||||
topStations: [station],
|
||||
regions: [{ region: "嘉兴市", kg: 12345.67, share: 100 }],
|
||||
stations: [station],
|
||||
customers: [customer],
|
||||
};
|
||||
|
||||
const dailyPoint = {
|
||||
date: "2026-08-31",
|
||||
kg: 123.45,
|
||||
lingniuKg: 100,
|
||||
externalKg: 23.45,
|
||||
cost: 234.56,
|
||||
recordCount: 2,
|
||||
stationCount: 1,
|
||||
};
|
||||
|
||||
export function devMockResponse(pathname: string) {
|
||||
if (pathname === "/api/health") return { status: "ok", source: "dev-mock" };
|
||||
if (pathname.endsWith("/meta")) return {
|
||||
years: [{ value: 2026, startDate: range.startDate, endDate: range.endDate }],
|
||||
stations: [{ id: station.id, name: station.name }],
|
||||
watermark,
|
||||
};
|
||||
if (pathname.endsWith("/overview")) return overview;
|
||||
if (pathname.endsWith("/daily-tree")) return {
|
||||
date: dailyPoint.date,
|
||||
stations: [{
|
||||
id: station.id,
|
||||
name: station.name,
|
||||
kg: dailyPoint.kg,
|
||||
cost: dailyPoint.cost,
|
||||
recordCount: 2,
|
||||
customers: [{ id: customer.id, name: customer.name, kg: dailyPoint.kg, cost: dailyPoint.cost, recordCount: 2 }],
|
||||
}],
|
||||
};
|
||||
if (pathname.endsWith("/daily")) return {
|
||||
range,
|
||||
watermark,
|
||||
filters: {},
|
||||
kpis: { totalKg: 12345.67, totalCost: 23456.78, averageDailyKg: 823.04, stationCount: 1, activeDays: 15 },
|
||||
trend: [dailyPoint],
|
||||
days: [dailyPoint],
|
||||
};
|
||||
if (pathname.endsWith("/drill")) return {
|
||||
groupBy: "record",
|
||||
amountScope: "all",
|
||||
filters: {},
|
||||
summary: { recordCount: 1, kg: 123.45, cost: 4000, revenue: 4320 },
|
||||
groups: [{ ...station, stationCount: 1, customerCount: 1 }],
|
||||
records: [{
|
||||
id: 1,
|
||||
time: "2026-08-31 12:00:00",
|
||||
orderNo: "DEV-001",
|
||||
stationId: station.id,
|
||||
stationName: station.name,
|
||||
customerId: customer.id,
|
||||
customerName: customer.name,
|
||||
plateNo: "浙FDEV01",
|
||||
source: "dev-mock",
|
||||
verifyStatus: "verified",
|
||||
vehicleId: 1,
|
||||
kg: 123.45,
|
||||
unitPrice: 35,
|
||||
cost: 4000,
|
||||
revenue: 4320,
|
||||
}],
|
||||
page: { page: 1, pageSize: 100, hasMore: false },
|
||||
};
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function sendJson(response: ServerResponse, body: unknown) {
|
||||
response.statusCode = 200;
|
||||
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
export function energyDevMockApi(): Plugin {
|
||||
return {
|
||||
name: "energy-dev-mock-api",
|
||||
configureServer(server) {
|
||||
server.middlewares.use((request: IncomingMessage, response: ServerResponse, next) => {
|
||||
const pathname = new URL(request.url ?? "/", "http://localhost").pathname;
|
||||
if (pathname === "/favicon.ico") {
|
||||
response.statusCode = 204;
|
||||
return response.end();
|
||||
}
|
||||
const body = devMockResponse(pathname);
|
||||
if (body === undefined) return next();
|
||||
sendJson(response, body);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { finiteNumber, formatNumber, formatScaled } from "./display-format";
|
||||
|
||||
test("能源看板格式化边界区分真实零值与不可用值", () => {
|
||||
for (const value of [null, undefined, NaN, Infinity, -Infinity, "0"]) {
|
||||
assert.equal(finiteNumber(value), null);
|
||||
assert.equal(formatNumber(value), "—");
|
||||
assert.equal(formatScaled(value, 1000), "—");
|
||||
}
|
||||
assert.equal(formatNumber(0), "0.00");
|
||||
assert.equal(formatScaled(0, 1000), "0.00");
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
export const finiteNumber = (value: unknown): number | null =>
|
||||
typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
|
||||
export const formatNumber = (value: unknown, digits = 2): string => {
|
||||
const safe = finiteNumber(value);
|
||||
return safe === null
|
||||
? "—"
|
||||
: safe.toLocaleString("zh-CN", {
|
||||
maximumFractionDigits: digits,
|
||||
minimumFractionDigits: digits,
|
||||
});
|
||||
};
|
||||
|
||||
export const formatScaled = (value: unknown, divisor: number, digits = 2) => {
|
||||
const safe = finiteNumber(value);
|
||||
return safe === null ? "—" : formatNumber(safe / divisor, digits);
|
||||
};
|
||||
@@ -0,0 +1,659 @@
|
||||
.ehb-drill-modal--unified .ehb-modal-body { padding: 18px; background: #f6f8fb; }
|
||||
.ehb-drill-modal--unified .ehb-drill-root-tabs { display: flex; justify-content: flex-end; margin: 0 0 12px; }
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-bar {
|
||||
display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0;
|
||||
padding: 0; overflow: hidden; border: 1px solid #dfe7f0; border-radius: 8px; background: #fff;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-item { min-width: 0; padding: 12px 16px; border-right: 1px solid #dfe7f0; }
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-item:last-child { border-right: 0; }
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-label { color: #64748b; }
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-val { color: #172238; font-family: var(--bi-font-mono); font-weight: 750; }
|
||||
.ehb-drill-modal--unified .ehb-modal-filter-row {
|
||||
gap: 8px; padding: 10px 12px; margin: 12px 0; border: 1px solid #dfe7f0;
|
||||
border-radius: 8px; background: #fff;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-filter-group { width: 100%; gap: 8px; }
|
||||
.ehb-drill-modal--unified .ehb-modal-hint-text { flex: 1 1 280px; min-width: 220px; color: #7b8aa1; }
|
||||
.ehb-drill-modal--unified .ehb-modal-table-wrap { border-color: #d7e1ed; border-radius: 8px; background: #fff; }
|
||||
.ehb-drill-modal--unified .ehb-modal-table th {
|
||||
height: 44px; padding: 10px 12px; border-bottom-color: #d7e1ed;
|
||||
background: #f0f3f8; color: #52627a;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-table > tbody > tr > td {
|
||||
height: 48px; padding-block: 10px; border-bottom-color: #e5ebf2; color: #334155;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row--station > td { background: #edf3fa; }
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row--customer > td { background: #f5f7fa; }
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row--vehicle > td { background: #fafbfd; }
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row:hover > td { background: #e8f0fb; }
|
||||
.ehb-drill-modal--unified .ehb-tree-toggle {
|
||||
display: inline-grid; width: 24px; min-width: 24px; height: 24px; place-items: center;
|
||||
padding: 0; border: 0; border-radius: 5px; background: transparent; color: #2f6bff;
|
||||
font-weight: 800; cursor: pointer;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-tree-toggle:hover,
|
||||
.ehb-drill-modal--unified .ehb-tree-toggle:focus-visible { background: #eaf1ff; outline: none; }
|
||||
.ehb-drill-modal--unified .ehb-tree-drill-link {
|
||||
margin-left: auto; padding: 0; border: 0; background: transparent; color: #2f6bff;
|
||||
font: inherit; font-size: 12px; font-weight: 700; white-space: nowrap; cursor: pointer;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-tree-drill-link:hover,
|
||||
.ehb-drill-modal--unified .ehb-tree-drill-link:focus-visible { color: #174ebc; text-decoration: underline; outline: none; }
|
||||
.ehb-drill-modal--unified .ehb-modal-table td[style*="text-align: right"] {
|
||||
color: #334155; font-family: var(--bi-font-mono); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-volume { color: #2f6bff !important; font-weight: 750; }
|
||||
.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-income { color: #2c8a78 !important; font-weight: 750; }
|
||||
.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-cost { color: #e28a24 !important; font-weight: 750; }
|
||||
.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-profit { color: #2f6bff !important; font-weight: 750; }
|
||||
.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-external { color: #7ea5ec !important; font-weight: 750; }
|
||||
.ehb-drill-modal--unified .ehb-summary-volume,
|
||||
.ehb-drill-modal--unified .ehb-summary-profit { color: #2f6bff; }
|
||||
.ehb-drill-modal--unified .ehb-summary-income { color: #2c8a78; }
|
||||
.ehb-drill-modal--unified .ehb-summary-cost { color: #e28a24; }
|
||||
.ehb-drill-modal--unified .ehb-flat-drill-table tbody tr:nth-child(even) td { background: #fafbfd; }
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row--date td { background: #fff; }
|
||||
.ehb-drill-modal--unified .ehb-day-change.is-up { color: #18a67a; font-weight: 700; }
|
||||
.ehb-drill-modal--unified .ehb-day-change.is-down { color: #e26464; font-weight: 700; }
|
||||
.ehb-drill-modal--unified .ehb-bearer-tag.is-cust { color: #b86606; border: 1px solid #f2d26c; background: #fffbea; }
|
||||
.ehb-drill-modal--unified .ehb-bearer-tag.is-lingniu { color: #2f6bff; border: 1px solid #bdd0fb; background: #eef4ff; }
|
||||
.ehb-drill-modal--unified .ehb-bearer-tag.is-other { color: #64748b; border: 1px solid #cbd5e1; background: #f8fafc; }
|
||||
.ehb-drill-modal--unified .ehb-tag--source-api,
|
||||
.ehb-drill-modal--unified .ehb-tag--source-station,
|
||||
.ehb-drill-modal--unified .ehb-tag--source-lingniu {
|
||||
border: 1px solid #cbd9e8; background: #eef4f9; color: #526b88;
|
||||
}
|
||||
.ehb-drill-local-error {
|
||||
display: flex;
|
||||
min-height: 180px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 24px;
|
||||
color: #475569;
|
||||
text-align: center;
|
||||
}
|
||||
.ehb-drill-local-error strong { color: #b42318; font-size: 16px; }
|
||||
.ehb-drill-local-error span { max-width: 520px; line-height: 1.6; }
|
||||
.ehb-drill-loading {
|
||||
position: relative;
|
||||
min-height: 300px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
animation: ehb-drill-fade-in .18s ease-out both;
|
||||
}
|
||||
.ehb-drill-loading__progress {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
width: 38%;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, transparent, #2f6bff 45%, #67b5ff, transparent);
|
||||
animation: ehb-drill-progress 1.15s ease-in-out infinite;
|
||||
}
|
||||
.ehb-drill-loading__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 22px 24px 18px;
|
||||
border-bottom: 1px solid #e7edf5;
|
||||
color: #1e293b;
|
||||
}
|
||||
.ehb-drill-loading__label > span:last-child {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.ehb-drill-loading__label strong { font-size: 14px; }
|
||||
.ehb-drill-loading__label small { color: #7b8aa0; font-size: 12px; }
|
||||
.ehb-drill-loading__spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex: 0 0 20px;
|
||||
border: 2px solid #dbe7fb;
|
||||
border-top-color: #2f6bff;
|
||||
border-radius: 50%;
|
||||
animation: ehb-drill-spin .72s linear infinite;
|
||||
}
|
||||
.ehb-drill-loading__rows { padding: 4px 18px 18px; }
|
||||
.ehb-drill-loading__row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 2.2fr) repeat(3, minmax(90px, 1fr));
|
||||
gap: 28px;
|
||||
align-items: center;
|
||||
min-width: 680px;
|
||||
height: 48px;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
}
|
||||
.ehb-drill-loading__row i {
|
||||
display: block;
|
||||
height: 11px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(100deg, #edf2f8 20%, #f8fafc 42%, #e7eef8 64%);
|
||||
background-size: 220% 100%;
|
||||
animation: ehb-drill-shimmer 1.25s ease-in-out infinite;
|
||||
}
|
||||
.ehb-drill-loading__row i:nth-child(2) { width: 72%; }
|
||||
.ehb-drill-loading__row i:nth-child(3) { width: 58%; }
|
||||
.ehb-drill-loading__row i:nth-child(4) { width: 82%; }
|
||||
.ehb-modal-table-wrap.is-ready > .ehb-modal-table {
|
||||
animation: ehb-drill-content-in .24s ease-out both;
|
||||
}
|
||||
@keyframes ehb-drill-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes ehb-drill-progress {
|
||||
0% { transform: translateX(-110%); opacity: 0; }
|
||||
20% { opacity: 1; }
|
||||
80% { opacity: 1; }
|
||||
100% { transform: translateX(370%); opacity: 0; }
|
||||
}
|
||||
@keyframes ehb-drill-shimmer { to { background-position: -220% 0; } }
|
||||
@keyframes ehb-drill-fade-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes ehb-drill-content-in {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ehb-drill-loading,
|
||||
.ehb-drill-loading__progress,
|
||||
.ehb-drill-loading__spinner,
|
||||
.ehb-drill-loading__row i,
|
||||
.ehb-modal-table-wrap.is-ready > .ehb-modal-table { animation: none; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-bar { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-item:nth-child(2) { border-right: 0; }
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-item:nth-child(-n + 2) { border-bottom: 1px solid #dfe7f0; }
|
||||
}
|
||||
.ehb-real-drill-filter-summary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.ehb-drill-modal--unified .ehb-modal-head {
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-head__title-group {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-head__title {
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-head__sub {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-head__actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-head__actions .mobile-list-fullscreen-trigger {
|
||||
position: static;
|
||||
min-width: 76px;
|
||||
height: 32px;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-body {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-item {
|
||||
padding: 9px 10px;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-table th:first-child,
|
||||
.ehb-drill-modal--unified .ehb-modal-table td:first-child {
|
||||
width: 235px;
|
||||
min-width: 235px;
|
||||
}
|
||||
|
||||
/* 宽表左右移动时首列保留完整的站点/客户名称、层级三角和同省标识。 */
|
||||
.ehb-drill-modal--unified .ehb-modal-table-wrap { overflow: auto !important; }
|
||||
.ehb-drill-modal--unified .ehb-modal-table { min-width: max-content; }
|
||||
.ehb-drill-modal--unified .ehb-modal-table th:first-child,
|
||||
.ehb-drill-modal--unified .ehb-modal-table td:first-child {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
background: #fff;
|
||||
box-shadow: 6px 0 10px -10px #0f172a;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-table thead th:first-child {
|
||||
z-index: 5;
|
||||
background: #f0f3f8;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row--station > td:first-child { background: #edf3fa; }
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row--customer > td:first-child { background: #f5f7fa; }
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row--vehicle > td:first-child { background: #fafbfd; }
|
||||
|
||||
.ehb-real-drill-filter-summary {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #dbe4f1;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ehb-real-drill-filter-summary > span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #2f6bff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ehb-real-drill-filter-summary strong {
|
||||
overflow: hidden;
|
||||
color: #1e293b;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ehb-real-drill-filter-summary > svg {
|
||||
transition: transform .18s ease;
|
||||
}
|
||||
|
||||
.ehb-real-drill-filter-summary > svg.is-open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.ehb-real-drill-filter-panel:not(.is-open) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ehb-real-drill-filter-panel.is-open {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-hint-text {
|
||||
width: 100%;
|
||||
overflow: visible !important;
|
||||
white-space: normal !important;
|
||||
text-overflow: clip !important;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-search-input,
|
||||
.ehb-drill-modal--unified .ehb-modal-search-input input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
/* 小屏横向空间充足时,把摘要和筛选压成一行,优先留高度给下钻表格。 */
|
||||
@media (max-height: 500px) and (orientation: landscape) {
|
||||
.ehb-drill-modal--unified .ehb-modal-head { padding-block: 6px; }
|
||||
.ehb-drill-modal--unified .ehb-modal-head__sub { display: inline; margin-left: 8px; }
|
||||
.ehb-drill-modal--unified .ehb-modal-body { padding: 6px 8px 8px; }
|
||||
.ehb-drill-modal--unified .ehb-drill-root-tabs { margin-bottom: 6px; }
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-bar {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-item {
|
||||
padding: 6px 10px;
|
||||
border-right: 1px solid #dfe7f0;
|
||||
border-bottom: 0 !important;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-item:last-child { border-right: 0; }
|
||||
.ehb-drill-modal--unified .ehb-modal-filter-row { margin-bottom: 6px; padding: 6px; }
|
||||
.ehb-drill-modal--unified .ehb-modal-hint-text { display: none; }
|
||||
.ehb-drill-modal--unified .ehb-drill-loading { min-height: 150px; }
|
||||
}
|
||||
|
||||
/* “横屏查看”是页面布局模式:不依赖手机方向锁定。 */
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head {
|
||||
flex: 0 0 auto;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-body {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 7px 8px 8px;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-meta-bar {
|
||||
grid-template-columns: repeat(4, minmax(150px, 1fr));
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-meta-item {
|
||||
padding: 6px 9px;
|
||||
border-right: 1px solid #dfe7f0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-filter-row {
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 6px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-table-wrap {
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: auto !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-hint-text,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-real-drill-filter-summary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 下钻路径是导航,不是装饰:允许直接回到任一上级。 */
|
||||
.ehb-drill-breadcrumbs {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 9px 18px;
|
||||
overflow-x: auto;
|
||||
border-bottom: 1px solid #dfe7f0;
|
||||
background: #fff;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.ehb-drill-breadcrumbs::-webkit-scrollbar { display: none; }
|
||||
.ehb-drill-breadcrumb { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 5px; }
|
||||
.ehb-drill-breadcrumb i { color: #9aabc0; font-style: normal; }
|
||||
.ehb-drill-breadcrumb button {
|
||||
max-width: 210px;
|
||||
padding: 4px 7px;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #3564a5;
|
||||
font: 650 12px/1.3 var(--bi-font);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ehb-drill-breadcrumb button:not(:disabled):hover { background: #edf4ff; color: #1f5fe0; }
|
||||
.ehb-drill-breadcrumb button[aria-current="page"] {
|
||||
background: #edf3fc;
|
||||
color: #24344e;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.ehb-drill-modal--unified {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-head { flex: 0 0 auto !important; }
|
||||
.ehb-drill-modal--unified .ehb-modal-close-btn { display: none !important; }
|
||||
.ehb-drill-modal--unified .ehb-modal-back-btn {
|
||||
min-width: 108px !important;
|
||||
min-height: 42px !important;
|
||||
justify-content: center;
|
||||
border-color: rgb(255 255 255 / 28%) !important;
|
||||
background: rgb(255 255 255 / 10%) !important;
|
||||
color: #fff !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
.ehb-drill-breadcrumbs {
|
||||
padding: 8px 10px;
|
||||
box-shadow: 0 3px 10px rgb(32 55 89 / 6%);
|
||||
}
|
||||
.ehb-drill-breadcrumb button { max-width: 150px; min-height: 30px; font-size: 11px; }
|
||||
.ehb-drill-modal--unified .ehb-modal-body {
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
flex: 1 1 auto !important;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-tree-drill-link { font-size: 10px !important; }
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row { min-height: 58px; }
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row > td:first-child { min-width: 220px !important; }
|
||||
.ehb-drill-modal--unified .ehb-tree-node-title { display: flex; align-items: center; gap: 4px; }
|
||||
.ehb-drill-modal--unified .ehb-tree-node-name {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
flex: 1 1 240px;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-tree-node-name strong { min-width: 0; overflow-wrap: anywhere; }
|
||||
.ehb-drill-modal--unified .ehb-tree-expanded-row > td { padding: 0 !important; background: #f8fbff; }
|
||||
.ehb-drill-modal--unified .ehb-tree-expanded-content { padding: 8px 14px 10px 38px; border-bottom: 1px solid #dbe7f5; }
|
||||
.ehb-drill-modal--unified .ehb-tree-expanded-content ul { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 5px 10px; margin: 0; padding: 0; list-style: none; }
|
||||
.ehb-drill-modal--unified .ehb-tree-expanded-content li { display: flex; justify-content: space-between; gap: 8px; padding: 5px 7px; color: #31578c; }
|
||||
.ehb-drill-modal--unified .ehb-tree-expanded-all { margin: 8px 7px 0; padding: 3px 0; border: 0; background: transparent; color: #2563eb; font: inherit; font-size: 12px; font-weight: 700; cursor: pointer; }
|
||||
.ehb-drill-modal--unified .ehb-tree-expanded-all:hover { text-decoration: underline; }
|
||||
.ehb-drill-modal--unified .ehb-tree-expanded-content small { color: #64748b; white-space: nowrap; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) and (orientation: landscape) {
|
||||
.ehb-drill-modal--unified .ehb-modal-table th:first-child,
|
||||
.ehb-drill-modal--unified .ehb-modal-table td:first-child { width: 300px; min-width: 300px; }
|
||||
}
|
||||
|
||||
/* 竖屏宽表保持原方向;表格容器负责横向滚动,绝不旋转整个页面。 */
|
||||
@media (max-width: 767px) and (orientation: portrait) {
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
padding: 0 !important;
|
||||
overflow: auto !important;
|
||||
background: #f4f7fb !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table-wrap {
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
min-height: 240px !important;
|
||||
border-radius: 8px !important;
|
||||
overflow: auto !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table {
|
||||
min-width: 960px !important;
|
||||
font-size: 10px !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table th,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table td {
|
||||
min-width: 112px !important;
|
||||
height: 38px !important;
|
||||
min-height: 38px !important;
|
||||
padding: 6px 8px !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table th:first-child,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table td:first-child {
|
||||
width: 190px !important;
|
||||
min-width: 190px !important;
|
||||
max-width: 190px !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback::after {
|
||||
right: 8px !important;
|
||||
bottom: 8px !important;
|
||||
padding: 4px 8px !important;
|
||||
font-size: 10px !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback * {
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 原生横屏与旋转兼容统一进入专注阅读状态。 */
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-drill-breadcrumbs,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-drill-root-tabs,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-meta-bar,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-real-drill-filter-summary,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-filter-row,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-drill-usage-guide {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head {
|
||||
min-height: 42px !important;
|
||||
height: 42px !important;
|
||||
padding: 4px 142px 4px 8px !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-back-btn,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head__sub {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 专注阅读仍是可导航的下钻页面;必须能逐层返回,不能要求先退出宽表。 */
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-back-btn {
|
||||
display: inline-flex !important;
|
||||
width: 44px !important;
|
||||
min-width: 44px !important;
|
||||
min-height: 44px !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head {
|
||||
padding: 2px 88px 2px 8px !important;
|
||||
}
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-back-btn > span {
|
||||
display: none;
|
||||
}
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head__title-group {
|
||||
display: grid !important;
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head__title-group > div {
|
||||
min-width: 0;
|
||||
}
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head__title {
|
||||
max-width: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-real-drill-primary-actions,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .mobile-list-fullscreen-trigger {
|
||||
width: 80px !important;
|
||||
min-width: 80px !important;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
/* 让三角、名称和轻量下钻提示在同一行网格内分配空间;长名称仅在名称格内换行。 */
|
||||
.ehb-drill-modal--unified .ehb-tree-node-title {
|
||||
display: grid !important;
|
||||
grid-template-columns: 28px minmax(0, 1fr) 24px;
|
||||
align-items: start;
|
||||
gap: 3px;
|
||||
width: 100%;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-tree-toggle { width: 28px; min-width: 28px; height: 28px; }
|
||||
.ehb-drill-modal--unified .ehb-tree-node-name {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
flex: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-tree-node-name .ehb-tree-node-sub { margin-left: 4px; }
|
||||
.ehb-drill-modal--unified .ehb-tree-drill-link {
|
||||
display: inline-grid !important;
|
||||
width: 24px !important;
|
||||
min-width: 24px !important;
|
||||
height: 28px !important;
|
||||
place-items: center;
|
||||
margin: 0 !important;
|
||||
overflow: hidden;
|
||||
color: transparent !important;
|
||||
font-size: 0 !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-tree-drill-link::after { content: "›" !important; color: #2563eb; font-size: 20px; line-height: 1; }
|
||||
|
||||
/* 普通弹层的筛选和宽表入口各占一个网格列;退出宽表后不保留绝对定位层。 */
|
||||
.ehb-drill-modal--unified:not([data-mobile-fullscreen-active="true"]) .ehb-real-drill-primary-actions {
|
||||
position: static !important;
|
||||
display: grid !important;
|
||||
grid-template-columns: minmax(0, 1fr) 112px;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.ehb-drill-modal--unified:not([data-mobile-fullscreen-active="true"]) .mobile-list-fullscreen-trigger.is-inline {
|
||||
position: static !important;
|
||||
inset: auto !important;
|
||||
width: 112px !important;
|
||||
min-width: 112px !important;
|
||||
min-height: 48px !important;
|
||||
}
|
||||
.ehb-drill-modal--unified:not([data-mobile-fullscreen-active="true"]) .mobile-list-fullscreen-trigger.is-inline::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-real-drill-primary-actions {
|
||||
position: absolute !important;
|
||||
z-index: 280 !important;
|
||||
top: 5px !important;
|
||||
right: 8px !important;
|
||||
display: block !important;
|
||||
width: 126px !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .mobile-list-fullscreen-trigger {
|
||||
position: static !important;
|
||||
display: inline-flex !important;
|
||||
width: 126px !important;
|
||||
min-width: 126px !important;
|
||||
height: 32px !important;
|
||||
min-height: 32px !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-body {
|
||||
display: flex !important;
|
||||
min-height: 0 !important;
|
||||
flex: 1 1 auto !important;
|
||||
padding: 5px 6px 6px !important;
|
||||
gap: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-table-wrap {
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
min-height: 0 !important;
|
||||
max-height: none !important;
|
||||
flex: 1 1 auto !important;
|
||||
margin: 0 !important;
|
||||
overflow: auto !important;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
test("能源氢费 BI 入口与独立验收地址复用同一看板", () => {
|
||||
const entry = readFileSync(new URL("../HydrogenModule.tsx", import.meta.url), "utf8");
|
||||
const app = readFileSync(new URL("../../../App.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(entry, /vendor\/lnbi-8113-exact\/prototypes\/energy-h2-bi-board\/EnergyBiBoardApp/);
|
||||
assert.match(entry, /return <EnergyBiBoardApp \/>/);
|
||||
assert.match(app, /vendor\/lnbi-8113-exact\/prototypes\/energy-h2-bi-board\/EnergyBiBoardApp/);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createServer } from "vite";
|
||||
import type { H2BiOverviewResponse } from "./types";
|
||||
|
||||
const point = (month: string, totalKg: number) => ({ month, totalKg }) as H2BiOverviewResponse["monthly"][number];
|
||||
|
||||
test("月度环比使用最近两个有效月份且不伪造不可用值", async () => {
|
||||
const vite = await createServer({ server: { middlewareMode: true }, appType: "custom", optimizeDeps: { noDiscovery: true } });
|
||||
try {
|
||||
const { monthlyChange } = await vite.ssrLoadModule("/src/modules/energy/hydrogen-bi-v2/EnergyOperationsBoard.tsx");
|
||||
assert.deepEqual(monthlyChange([point("2026-06", 80), point("2026-08", 120), point("2026-07", 100)]), { value: "+20.0%", detail: "8月较7月" });
|
||||
assert.deepEqual(monthlyChange([point("2026-07", 0), point("2026-08", 120)]), { value: "—", detail: "暂不可用" });
|
||||
assert.deepEqual(monthlyChange([point("2026-08", 120)]), { value: "—", detail: "暂不可用" });
|
||||
assert.deepEqual(monthlyChange([point("2026-07", 100), point("2026-08", Number.NaN)]), { value: "—", detail: "暂不可用" });
|
||||
} finally {
|
||||
await vite.close();
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Download, RefreshCw, Truck } from "lucide-react";
|
||||
import { DailyTreeButton, DailyBranchState } from "./daily-detail-controls";
|
||||
import { dailySummaryRows, formatDailyChange } from "./daily-detail-format";
|
||||
import { fetchH2BiDaily, fetchH2BiDailyTree, fetchH2BiDrill } from "./api";
|
||||
import { downloadExcelAoa } from "./prototype-download";
|
||||
import "./real-daily-mobile.css";
|
||||
import type {
|
||||
H2BiDailyResponse,
|
||||
H2BiDailyTreeResponse,
|
||||
@@ -40,7 +43,10 @@ export function PrototypeRealDailyView({
|
||||
fleetScope,
|
||||
onFleetScopeChange,
|
||||
verifyScope,
|
||||
stationId = null,
|
||||
onRefresh,
|
||||
refreshToken = 0,
|
||||
onLoadingChange,
|
||||
}: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
@@ -49,7 +55,10 @@ export function PrototypeRealDailyView({
|
||||
fleetScope: "all" | "own" | "external";
|
||||
onFleetScopeChange: (value: "all" | "own" | "external") => void;
|
||||
verifyScope: "all" | "verified";
|
||||
stationId?: string | number | null;
|
||||
onRefresh: () => void;
|
||||
refreshToken?: number;
|
||||
onLoadingChange?: (loading: boolean) => void;
|
||||
}) {
|
||||
const query = useMemo<H2BiQuery>(
|
||||
() => ({
|
||||
@@ -58,10 +67,13 @@ export function PrototypeRealDailyView({
|
||||
endDate,
|
||||
vehicleScope: toScope(fleetScope),
|
||||
verifyScope,
|
||||
stationId,
|
||||
}),
|
||||
[endDate, fleetScope, startDate, verifyScope],
|
||||
[endDate, fleetScope, startDate, stationId, verifyScope],
|
||||
);
|
||||
const [daily, setDaily] = useState<H2BiDailyResponse | null>(null);
|
||||
const [reloadToken, setReloadToken] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [trees, setTrees] = useState<Record<string, H2BiDailyTreeResponse>>({});
|
||||
const [expandedDate, setExpandedDate] = useState<string | null>(null);
|
||||
const [expandedStation, setExpandedStation] = useState<
|
||||
@@ -84,9 +96,24 @@ export function PrototypeRealDailyView({
|
||||
>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [highlightedDate, setHighlightedDate] = useState<string | null>(null);
|
||||
const [detailMode, setDetailMode] = useState<"key" | "full">("key");
|
||||
const [branchErrors, setBranchErrors] = useState<Record<string, string>>({});
|
||||
const requestGeneration = useRef(0);
|
||||
const pendingBranches = useRef(new Set<string>());
|
||||
const tableWrapRef = useRef<HTMLDivElement>(null);
|
||||
const dateRowRefs = useRef<Record<string, HTMLTableRowElement | null>>({});
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
requestGeneration.current += 1;
|
||||
pendingBranches.current.clear();
|
||||
setBranchErrors({});
|
||||
let finishTimer: number | undefined;
|
||||
const loadingStartedAt = Date.now();
|
||||
setIsLoading(true);
|
||||
onLoadingChange?.(true);
|
||||
// The selected range is a new data contract. Clear the former response so
|
||||
// an API error can never be mistaken for fresh data or business zeroes.
|
||||
setDaily(null);
|
||||
setError(null);
|
||||
setTrees({});
|
||||
setExpandedDate(null);
|
||||
@@ -99,22 +126,50 @@ export function PrototypeRealDailyView({
|
||||
void fetchH2BiDaily(query)
|
||||
.then((result) => alive && setDaily(result))
|
||||
.catch(
|
||||
(reason: unknown) =>
|
||||
alive &&
|
||||
(reason: unknown) => {
|
||||
if (!alive) return;
|
||||
setDaily(null);
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : "按日数据加载失败",
|
||||
),
|
||||
);
|
||||
);
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
if (!alive) return;
|
||||
const remaining = Math.max(0, 500 - (Date.now() - loadingStartedAt));
|
||||
finishTimer = window.setTimeout(() => {
|
||||
if (!alive) return;
|
||||
setIsLoading(false);
|
||||
onLoadingChange?.(false);
|
||||
}, remaining);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
requestGeneration.current += 1;
|
||||
if (finishTimer !== undefined) window.clearTimeout(finishTimer);
|
||||
};
|
||||
}, [query]);
|
||||
}, [query, reloadToken, refreshToken, onLoadingChange]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setReloadToken((value) => value + 1);
|
||||
onRefresh();
|
||||
};
|
||||
const ensureDateTree = (date: string) => {
|
||||
if (trees[date]) return;
|
||||
if (trees[date] || pendingBranches.current.has(date)) return;
|
||||
const generation = requestGeneration.current;
|
||||
pendingBranches.current.add(date);
|
||||
setBranchErrors((items) => ({ ...items, [date]: "" }));
|
||||
void fetchH2BiDailyTree(date, {
|
||||
vehicleScope: query.vehicleScope,
|
||||
verifyScope,
|
||||
}).then((tree) => setTrees((items) => ({ ...items, [date]: tree })));
|
||||
stationId,
|
||||
}).then((tree) => {
|
||||
if (generation === requestGeneration.current) setTrees((items) => ({ ...items, [date]: tree }));
|
||||
}).catch(() => {
|
||||
if (generation === requestGeneration.current) setBranchErrors((items) => ({ ...items, [date]: "站点明细加载失败,请重试" }));
|
||||
}).finally(() => {
|
||||
if (generation === requestGeneration.current) pendingBranches.current.delete(date);
|
||||
});
|
||||
};
|
||||
const openDate = (date: string, scrollIntoDate = false) => {
|
||||
setExpandedDate(date);
|
||||
@@ -123,7 +178,7 @@ export function PrototypeRealDailyView({
|
||||
setHighlightedDate(date);
|
||||
window.setTimeout(() => {
|
||||
dateRowRefs.current[date]?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
|
||||
block: "center",
|
||||
});
|
||||
}, 40);
|
||||
@@ -138,14 +193,16 @@ export function PrototypeRealDailyView({
|
||||
}
|
||||
openDate(date);
|
||||
};
|
||||
const toggleCustomer = (
|
||||
const loadCustomer = (
|
||||
date: string,
|
||||
stationId: string | number,
|
||||
customerId: number,
|
||||
) => {
|
||||
const key = `${date}:${stationId}:${customerId}`;
|
||||
setExpandedCustomer((items) => ({ ...items, [key]: !items[key] }));
|
||||
if (customerRecords[key]) return;
|
||||
if (customerRecords[key] || pendingBranches.current.has(key)) return;
|
||||
const generation = requestGeneration.current;
|
||||
pendingBranches.current.add(key);
|
||||
setBranchErrors((items) => ({ ...items, [key]: "" }));
|
||||
void fetchH2BiDrill({
|
||||
...query,
|
||||
date,
|
||||
@@ -154,32 +211,25 @@ export function PrototypeRealDailyView({
|
||||
groupBy: "record",
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
}).then((result) =>
|
||||
setCustomerRecords((items) => ({ ...items, [key]: result })),
|
||||
);
|
||||
}).then((result) => {
|
||||
if (generation === requestGeneration.current) setCustomerRecords((items) => ({ ...items, [key]: result }));
|
||||
}).catch(() => {
|
||||
if (generation === requestGeneration.current) setBranchErrors((items) => ({ ...items, [key]: "车辆明细加载失败,请重试" }));
|
||||
}).finally(() => {
|
||||
if (generation === requestGeneration.current) pendingBranches.current.delete(key);
|
||||
});
|
||||
};
|
||||
const toggleCustomer = (date: string, station: string | number, customer: number) => {
|
||||
const key = `${date}:${station}:${customer}`;
|
||||
setExpandedCustomer((items) => ({ ...items, [key]: !items[key] }));
|
||||
loadCustomer(date, station, customer);
|
||||
};
|
||||
const exportRows = () => {
|
||||
const rows: Array<Array<string | number>> = [
|
||||
["日期", "加氢站", "客户", "加氢量(Kg)", "成本(元)", "流水笔数"],
|
||||
];
|
||||
Object.values(trees).forEach((tree) =>
|
||||
tree.stations.forEach((station) =>
|
||||
station.customers.forEach((customer) =>
|
||||
rows.push([
|
||||
tree.date,
|
||||
station.name,
|
||||
customer.name,
|
||||
customer.kg,
|
||||
customer.cost,
|
||||
customer.recordCount,
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!daily) return;
|
||||
downloadExcelAoa(
|
||||
rows,
|
||||
`每日加氢数据明细_${startDate}_${endDate}.xlsx`,
|
||||
"每日加氢明细",
|
||||
dailySummaryRows(daily),
|
||||
`每日加氢日期汇总_${startDate}_${endDate}.xlsx`,
|
||||
"日期汇总",
|
||||
);
|
||||
};
|
||||
const trend = daily?.trend ?? [];
|
||||
@@ -197,6 +247,13 @@ export function PrototypeRealDailyView({
|
||||
);
|
||||
return (
|
||||
<div className="ehb-daily-container">
|
||||
{isLoading && !daily ? (
|
||||
<div className="ehb-live-data-state is-loading" role="status" aria-live="polite">
|
||||
<span className="ehb-live-data-spinner" aria-hidden />
|
||||
<strong>正在读取真实日期统计</strong>
|
||||
<span>加载完成前不展示业务零值</span>
|
||||
</div>
|
||||
) : null}
|
||||
<section className="ehb-daily-filter-card">
|
||||
<div className="ehb-daily-filter-row">
|
||||
<div className="ehb-daily-filter-group">
|
||||
@@ -255,7 +312,7 @@ export function PrototypeRealDailyView({
|
||||
onClick={() => onFleetScopeChange("own")}
|
||||
>
|
||||
<Truck size={14} />
|
||||
仅羚牛车辆
|
||||
羚牛车辆
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -263,21 +320,24 @@ export function PrototypeRealDailyView({
|
||||
onClick={() => onFleetScopeChange("external")}
|
||||
>
|
||||
<Truck size={14} />
|
||||
仅外部车辆
|
||||
外部车辆
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="ehb-btn ehb-btn--ghost"
|
||||
onClick={onRefresh}
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
aria-busy={isLoading}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
刷新
|
||||
<RefreshCw size={14} className={isLoading ? "is-spinning" : ""} />
|
||||
{isLoading ? "加载中…" : "刷新"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{error ? <div className="ehb-empty">{error}</div> : null}
|
||||
{daily ? <>
|
||||
<section className="ehb-daily-kpi-grid">
|
||||
<div className="ehb-daily-kpi-card">
|
||||
<div className="ehb-daily-kpi-title">区间加氢量</div>
|
||||
@@ -413,7 +473,8 @@ export function PrototypeRealDailyView({
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
<section className="ehb-daily-table-card">
|
||||
</> : null}
|
||||
<section className="ehb-daily-table-card ehb-real-daily-detail" data-detail-mode={detailMode}>
|
||||
<div className="ehb-daily-table-head">
|
||||
<div className="ehb-daily-table-title">
|
||||
每日加氢数据明细{" "}
|
||||
@@ -425,25 +486,52 @@ export function PrototypeRealDailyView({
|
||||
type="button"
|
||||
className="ehb-btn ehb-btn--outline ehb-export-btn"
|
||||
onClick={exportRows}
|
||||
disabled={!daily || isLoading || !daily.days.length}
|
||||
title="导出所选区间的全部日期汇总,不含客户和车辆流水"
|
||||
>
|
||||
<Download size={14} />
|
||||
导出 Excel
|
||||
导出日期汇总
|
||||
</button>
|
||||
</div>
|
||||
<div className="ehb-daily-detail-toolbar">
|
||||
<div className="ehb-daily-view-switch" role="group" aria-label="明细显示方式">
|
||||
{([['key', '重点指标'], ['full', '完整表格']] as const).map(([mode, label]) =>
|
||||
<button type="button" key={mode} aria-pressed={detailMode === mode} onClick={() => {
|
||||
setDetailMode(mode);
|
||||
if (tableWrapRef.current) tableWrapRef.current.scrollLeft = 0;
|
||||
}}>{label}</button>)}
|
||||
</div>
|
||||
<label className="ehb-daily-date-jump">
|
||||
<span>定位日期</span>
|
||||
<select value={expandedDate ?? ""} disabled={!daily?.days.length} onChange={(event) => {
|
||||
if (event.target.value) openDate(event.target.value, true);
|
||||
}}>
|
||||
<option value="">选择日期</option>
|
||||
{(daily?.days ?? []).map(day => <option key={day.date} value={day.date}>{day.date}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" className="ehb-daily-collapse" disabled={!expandedDate} onClick={() => {
|
||||
setExpandedDate(null); setExpandedStation({}); setExpandedCustomer({});
|
||||
setExpandedStationLists({}); setExpandedCustomerLists({}); setExpandedRecordLists({});
|
||||
}}>全部收起</button>
|
||||
</div>
|
||||
<div
|
||||
className="ehb-h5-scroll-hint ehb-daily-table-scroll-hint"
|
||||
aria-hidden="true"
|
||||
>
|
||||
‹ 左右滑动查看完整指标列 ›
|
||||
{detailMode === "key" ? "点击名称逐层查看:日期 → 站点 → 客户 → 车辆" : "首列已固定 · 左右滑动查看全部指标"}
|
||||
</div>
|
||||
<div className="ehb-table-wrap">
|
||||
{!daily ? <div className="ehb-daily-detail-empty" role={error ? "alert" : "status"}>
|
||||
{error ? <>明细暂时无法加载<button type="button" onClick={handleRefresh}>重新加载</button></> : "正在加载日期明细…"}
|
||||
</div> : !daily.days.length ? <div className="ehb-daily-detail-empty" role="status">所选日期和车辆范围内暂无加氢记录,请调整筛选条件。</div> :
|
||||
<div ref={tableWrapRef} className="ehb-table-wrap" role="region" aria-label="每日加氢明细,可左右滚动" tabIndex={0}>
|
||||
<table className="ehb-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>日期 / 加氢站 / 客户 / 车辆明细</th>
|
||||
<th>日期 / 明细</th>
|
||||
<th>单价(元/Kg)</th>
|
||||
<th>加氢量(Kg)</th>
|
||||
<th>成本金额(元) / 环比</th>
|
||||
<th>成本(元) / 环比</th>
|
||||
<th>预充值余额 / 数据来源</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -467,28 +555,28 @@ export function PrototypeRealDailyView({
|
||||
id={`daily-row-${day.date}`}
|
||||
className={`ehb-daily-date-row${highlightedDate === day.date ? " is-highlighted" : ""}`}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
background: open ? "#f0f9ff" : undefined,
|
||||
}}
|
||||
onClick={() => toggleDate(day.date)}
|
||||
>
|
||||
<td>
|
||||
<span className="ehb-daily-tree-toggle is-date">
|
||||
{open ? "▼" : "►"}
|
||||
</span>
|
||||
<DailyTreeButton open={open} label={`${day.date}加氢站明细`} onClick={() => toggleDate(day.date)}>
|
||||
{day.date}{" "}
|
||||
<span className="ehb-title-sub">
|
||||
({day.stationCount ?? 0} 个加氢站)
|
||||
</span>
|
||||
</DailyTreeButton>
|
||||
</td>
|
||||
<td>—</td>
|
||||
<td>{format(day.kg)}</td>
|
||||
<td>
|
||||
¥{format(day.cost)} /{" "}
|
||||
{format((day as { chainPct?: number }).chainPct ?? 0)}%
|
||||
<strong className="ehb-daily-cost">{format(day.cost)}</strong>
|
||||
<small className="ehb-daily-change">{formatDailyChange((day as { chainPct?: number }).chainPct)}</small>
|
||||
</td>
|
||||
<td>暂无来源</td>
|
||||
</tr>
|
||||
{open && (!tree || tree.stations.length === 0) ? <DailyBranchState
|
||||
columns={detailMode === "key" ? 3 : 5} error={branchErrors[day.date]}
|
||||
empty={!!tree} onRetry={() => ensureDateTree(day.date)} /> : null}
|
||||
{open &&
|
||||
tree?.stations
|
||||
.slice(0, expandedStationLists[day.date] ? undefined : 10)
|
||||
@@ -499,28 +587,22 @@ export function PrototypeRealDailyView({
|
||||
<Fragment key={stationKey}>
|
||||
<tr
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
background: "#f8fafc",
|
||||
}}
|
||||
onClick={() =>
|
||||
setExpandedStation((items) => ({
|
||||
...items,
|
||||
[stationKey]: !items[stationKey],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<td className="ehb-tree-cell-l1">
|
||||
<span className="ehb-daily-tree-toggle is-station">
|
||||
{stationOpen ? "▼" : "►"}
|
||||
</span>
|
||||
<span className="ehb-daily-tree-branch">└</span>
|
||||
加氢站:{station.name}
|
||||
<DailyTreeButton open={stationOpen} label={`${station.name}客户明细`} onClick={() =>
|
||||
setExpandedStation(items => ({ ...items, [stationKey]: !items[stationKey] }))}>
|
||||
<small className="ehb-daily-level-label">加氢站</small>{station.name}
|
||||
</DailyTreeButton>
|
||||
</td>
|
||||
<td>—</td>
|
||||
<td>{format(station.kg)}</td>
|
||||
<td>¥{format(station.cost)}</td>
|
||||
<td>暂无来源</td>
|
||||
</tr>
|
||||
{stationOpen && station.customers.length === 0 ? <DailyBranchState
|
||||
columns={detailMode === "key" ? 3 : 5} empty onRetry={() => {}} /> : null}
|
||||
{stationOpen &&
|
||||
station.customers
|
||||
.slice(
|
||||
@@ -543,37 +625,29 @@ export function PrototypeRealDailyView({
|
||||
);
|
||||
return (
|
||||
<Fragment key={customerKey}>
|
||||
<tr
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() =>
|
||||
toggleCustomer(
|
||||
day.date,
|
||||
station.id,
|
||||
customer.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
<tr>
|
||||
<td className="ehb-tree-cell-l2">
|
||||
<span className="ehb-daily-tree-toggle is-customer">
|
||||
{customerOpen ? "▼" : "►"}
|
||||
</span>
|
||||
<span className="ehb-daily-tree-branch">└─</span>
|
||||
客户:{customer.name}{" "}
|
||||
<DailyTreeButton open={customerOpen} label={`${customer.name}车辆明细`}
|
||||
onClick={() => toggleCustomer(day.date, station.id, customer.id)}>
|
||||
<small className="ehb-daily-level-label">客户</small>{customer.name}{" "}
|
||||
<span className="ehb-title-sub">
|
||||
({customer.recordCount} 笔)
|
||||
</span>
|
||||
</DailyTreeButton>
|
||||
</td>
|
||||
<td>—</td>
|
||||
<td>{format(customer.kg)}</td>
|
||||
<td>¥{format(customer.cost)}</td>
|
||||
<td>点击查看真实流水</td>
|
||||
</tr>
|
||||
{customerOpen && (!customerRecords[customerKey] || allRecords.length === 0) ? <DailyBranchState
|
||||
columns={detailMode === "key" ? 3 : 5} error={branchErrors[customerKey]}
|
||||
empty={!!customerRecords[customerKey]} onRetry={() => loadCustomer(day.date, station.id, customer.id)} /> : null}
|
||||
{customerOpen &&
|
||||
records.map((record) => (
|
||||
<tr key={String(record.id)}>
|
||||
<td className="ehb-tree-cell-l3">
|
||||
<span className="ehb-daily-tree-branch">└──</span>
|
||||
{String(record.time || "—").slice(11, 16)}{" "}
|
||||
<small className="ehb-daily-level-label">车辆 · {String(record.time || "—").slice(11, 16)}</small>
|
||||
<strong>
|
||||
{String(record.plateNo || "无车牌")}
|
||||
</strong>{" "}
|
||||
@@ -613,7 +687,7 @@ export function PrototypeRealDailyView({
|
||||
))}
|
||||
{customerOpen && allRecords.length > 20 ? (
|
||||
<tr className="ehb-daily-tree-more-row">
|
||||
<td colSpan={5}>
|
||||
<td colSpan={detailMode === "key" ? 3 : 5}>
|
||||
<button
|
||||
type="button"
|
||||
className="ehb-daily-tree-more-btn"
|
||||
@@ -637,7 +711,7 @@ export function PrototypeRealDailyView({
|
||||
})}
|
||||
{stationOpen && station.customers.length > 10 ? (
|
||||
<tr className="ehb-daily-tree-more-row">
|
||||
<td colSpan={5}>
|
||||
<td colSpan={detailMode === "key" ? 3 : 5}>
|
||||
<button
|
||||
type="button"
|
||||
className="ehb-daily-tree-more-btn"
|
||||
@@ -661,7 +735,7 @@ export function PrototypeRealDailyView({
|
||||
})}
|
||||
{open && tree && tree.stations.length > 10 ? (
|
||||
<tr className="ehb-daily-tree-more-row">
|
||||
<td colSpan={5}>
|
||||
<td colSpan={detailMode === "key" ? 3 : 5}>
|
||||
<button
|
||||
type="button"
|
||||
className="ehb-daily-tree-more-btn"
|
||||
@@ -685,7 +759,7 @@ export function PrototypeRealDailyView({
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1242,6 +1242,48 @@
|
||||
color: var(--bi-text-body);
|
||||
}
|
||||
|
||||
.ehb-live-data-state {
|
||||
display: flex;
|
||||
min-height: 240px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 32px 20px;
|
||||
border: 1px solid #dbe4f1;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ehb-live-data-state strong {
|
||||
color: #172238;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.ehb-live-data-state.is-error {
|
||||
border-color: #fecaca;
|
||||
background: #fffafa;
|
||||
}
|
||||
|
||||
.ehb-live-data-state.is-error strong { color: #b42318; }
|
||||
|
||||
.ehb-live-data-spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid #dbe7ff;
|
||||
border-top-color: #2f6bff;
|
||||
border-radius: 50%;
|
||||
animation: ehb-live-data-spin .75s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ehb-live-data-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ehb-live-data-spinner { animation: none; }
|
||||
}
|
||||
|
||||
/* —— 宿主按日视图 (Daily View) 专用样式 —— */
|
||||
|
||||
.ehb-daily-filter-card {
|
||||
@@ -3663,6 +3705,36 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 最终桌面覆盖:承担金额不截断。 */
|
||||
@media (min-width: 768px) {
|
||||
.ehb-host-kpi .ehb-kpi-dual__deck.is-triple {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
|
||||
gap: 0 !important;
|
||||
}
|
||||
.ehb-host-kpi .ehb-kpi-dual__deck.is-triple .ehb-kpi-dual__detail {
|
||||
display: flex !important;
|
||||
min-width: 0 !important;
|
||||
flex-direction: column !important;
|
||||
align-items: flex-start !important;
|
||||
padding-inline: 7px !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
.ehb-host-kpi .ehb-kpi-dual__deck.is-triple .ehb-kpi-dual__detail:first-child { padding-left: 0 !important; }
|
||||
.ehb-host-kpi .ehb-kpi-dual__deck.is-triple .ehb-kpi-dual__detail:last-child { padding-right: 0 !important; }
|
||||
.ehb-host-kpi .ehb-kpi-dual__detail-label,
|
||||
.ehb-host-kpi .ehb-kpi-dual__detail-value {
|
||||
max-width: none !important;
|
||||
overflow: visible !important;
|
||||
text-overflow: clip !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
.ehb-host-kpi .ehb-kpi-dual__detail-value {
|
||||
font-size: 10px !important;
|
||||
letter-spacing: -0.04em !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Runtime data tree: use the prototype's table hierarchy while retaining real
|
||||
source/verification fields and a bounded first render for long query results. */
|
||||
.ehb-daily-tree-toggle {
|
||||
@@ -4575,6 +4647,48 @@
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* 累计 KPI 的三项承担构成必须完整可读,禁止金额省略。 */
|
||||
@media (min-width: 768px) {
|
||||
.ehb-host-kpi .ehb-kpi-dual__deck.is-triple {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
|
||||
align-items: start !important;
|
||||
gap: 0 !important;
|
||||
padding: 8px 10px !important;
|
||||
}
|
||||
|
||||
.ehb-host-kpi .ehb-kpi-dual__deck.is-triple .ehb-kpi-dual__detail {
|
||||
display: flex !important;
|
||||
min-width: 0 !important;
|
||||
flex-direction: column !important;
|
||||
align-items: flex-start !important;
|
||||
gap: 3px !important;
|
||||
padding: 0 8px !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.ehb-host-kpi .ehb-kpi-dual__deck.is-triple .ehb-kpi-dual__detail:first-child { padding-left: 0 !important; }
|
||||
.ehb-host-kpi .ehb-kpi-dual__deck.is-triple .ehb-kpi-dual__detail:last-child { padding-right: 0 !important; }
|
||||
.ehb-host-kpi .ehb-kpi-dual__deck.is-triple .ehb-kpi-dual__detail + .ehb-kpi-dual__detail { border-left: 1px solid #e4eaf2; }
|
||||
|
||||
.ehb-host-kpi .ehb-kpi-dual__detail-label,
|
||||
.ehb-host-kpi .ehb-kpi-dual__detail-value {
|
||||
display: block !important;
|
||||
max-width: none !important;
|
||||
overflow: visible !important;
|
||||
text-overflow: clip !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
|
||||
.ehb-host-kpi .ehb-kpi-dual__detail-label { color: #71819a; font: 600 10px/1.2 var(--bi-font); }
|
||||
.ehb-host-kpi .ehb-kpi-dual__detail-value {
|
||||
color: #26364f;
|
||||
font: 700 10px/1.25 var(--bi-font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
}
|
||||
.ehb-kpi-unit {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/* Scoped to the live daily table; keep the other boards and drill dialogs intact. */
|
||||
.ehb-real-daily-detail {
|
||||
min-width: 0;
|
||||
}
|
||||
.ehb-daily-detail-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 16px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.ehb-daily-view-switch {
|
||||
display: inline-flex;
|
||||
padding: 3px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 9px;
|
||||
}
|
||||
.ehb-real-daily-detail button,
|
||||
.ehb-real-daily-detail select { font: inherit; }
|
||||
.ehb-daily-view-switch button,
|
||||
.ehb-daily-collapse,
|
||||
.ehb-daily-branch-state button,
|
||||
.ehb-daily-detail-empty button {
|
||||
min-height: 40px;
|
||||
border: 0;
|
||||
padding: 8px 12px;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.ehb-daily-view-switch button[aria-pressed="true"] {
|
||||
background: #fff;
|
||||
color: #1d4ed8;
|
||||
box-shadow: 0 1px 4px #0f172a14;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ehb-daily-date-jump { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #64748b; }
|
||||
.ehb-daily-date-jump select { min-height: 40px; border: 1px solid #e2e8f0; border-radius: 7px; padding: 6px 8px; background: #fff; color: #334155; }
|
||||
.ehb-real-daily-detail button:disabled { opacity: .45; cursor: default; }
|
||||
.ehb-real-daily-detail :is(button, select, [tabindex]):focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
|
||||
.ehb-real-daily-detail .ehb-daily-disclosure {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 6px 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ehb-daily-disclosure > svg { flex-shrink: 0; margin-top: 3px; color: #64748b; }
|
||||
.ehb-daily-disclosure > span { min-width: 0; }
|
||||
.ehb-daily-disclosure[aria-expanded="true"] { font-weight: 600; }
|
||||
.ehb-daily-disclosure[aria-expanded="true"] > svg { color: #2563eb; }
|
||||
.ehb-daily-level-label,
|
||||
.ehb-daily-change { display: block; font-size: 11px; font-weight: 400; color: #64748b; line-height: 1.6; }
|
||||
.ehb-daily-cost { font-weight: 500; }
|
||||
.ehb-daily-branch-state > td > div { display: flex; align-items: center; gap: 8px; }
|
||||
.ehb-daily-branch-state button { display: inline-flex; align-items: center; gap: 5px; color: #1d4ed8; background: #eff6ff; }
|
||||
.ehb-daily-detail-empty { padding: 24px 16px; text-align: center; color: #64748b; font-size: 13px; }
|
||||
.ehb-daily-detail-empty button { display: block; margin: 8px auto 0; color: #1d4ed8; }
|
||||
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table { min-width: 0; }
|
||||
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table :is(th, td):is(:nth-child(2), :nth-child(5)) { display: none; }
|
||||
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table :is(th, td):first-child:not([colspan]) { width: 36%; }
|
||||
.ehb-real-daily-detail .ehb-table td:not(:first-child) { text-align: right; }
|
||||
.ehb-real-daily-detail .ehb-table th:not(:first-child) { text-align: right; }
|
||||
.ehb-real-daily-detail .ehb-table-wrap {
|
||||
isolation: isolate;
|
||||
overscroll-behavior-x: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table {
|
||||
table-layout: fixed;
|
||||
min-width: 940px;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table tr {
|
||||
background-color: #fff;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table th:first-child,
|
||||
.ehb-real-daily-detail .ehb-table td:first-child:not([colspan]) {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
width: 260px;
|
||||
background-color: inherit;
|
||||
box-shadow: 1px 0 0 #e2e8f0, 5px 0 8px -6px #64748b;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table th:first-child {
|
||||
z-index: 4;
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table th {
|
||||
z-index: 3;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table td:not(:first-child) {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@media (max-width: 767px), (max-width: 1024px) and (max-height: 500px) {
|
||||
.ehb-real-daily-detail.ehb-daily-table-card {
|
||||
padding: 12px 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-daily-table-head {
|
||||
padding: 0 12px;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-daily-table-title {
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-daily-table-title .ehb-title-sub {
|
||||
display: none;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-export-btn {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
min-height: 44px;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-daily-detail-toolbar { padding: 0 12px; gap: 8px; }
|
||||
.ehb-real-daily-detail .ehb-daily-view-switch { width: 100%; }
|
||||
.ehb-real-daily-detail .ehb-daily-view-switch button { flex: 1; min-height: 44px; }
|
||||
.ehb-real-daily-detail .ehb-daily-date-jump { flex: 1; }
|
||||
.ehb-real-daily-detail .ehb-daily-date-jump select { min-height: 44px; }
|
||||
.ehb-real-daily-detail .ehb-daily-collapse { min-height: 44px; padding: 6px; font-size: 12px; }
|
||||
.ehb-real-daily-detail .ehb-daily-table-scroll-hint {
|
||||
display: block;
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table-wrap {
|
||||
max-height: 65dvh;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table {
|
||||
min-width: 760px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table th:first-child,
|
||||
.ehb-real-daily-detail .ehb-table td:first-child:not([colspan]) {
|
||||
width: 142px;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table td {
|
||||
height: 48px;
|
||||
padding: 10px 12px;
|
||||
line-height: 1.5;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table { min-width: 0; width: 100%; }
|
||||
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table :is(th, td) { padding: 10px 8px; }
|
||||
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table td:not(:first-child) { font-size: 12px; overflow-wrap: anywhere; }
|
||||
.ehb-real-daily-detail .ehb-table td:first-child:has(.ehb-daily-disclosure) { padding-top: 4px; padding-bottom: 4px; }
|
||||
.ehb-real-daily-detail .ehb-table th {
|
||||
padding: 10px 12px;
|
||||
white-space: normal;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table td:first-child .ehb-title-sub {
|
||||
display: block;
|
||||
margin-left: 0;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table td.ehb-tree-cell-l1 { padding-left: 16px; }
|
||||
.ehb-real-daily-detail .ehb-table td.ehb-tree-cell-l2 { padding-left: 22px; }
|
||||
.ehb-real-daily-detail .ehb-table td.ehb-tree-cell-l3 { padding-left: 28px; }
|
||||
.ehb-real-daily-detail .ehb-daily-record-tag {
|
||||
display: inline-block;
|
||||
margin: 2px 3px 0 0;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
export type H2BiScope = "global" | "station";
|
||||
export type H2BiView = "overview" | "daily";
|
||||
export type H2BiVehicleScope = "all" | "lingniu" | "external";
|
||||
export type H2BiVerifyScope = "all" | "verified";
|
||||
export type H2BiVerifyScope = "all" | "verified" | "unverified";
|
||||
/** 账本 settlement_type 的承担口径;下钻、KPI 与趋势图必须使用同一口径。 */
|
||||
export type H2BiAmountScope = "all" | "customer" | "company" | "other";
|
||||
export type H2BiRegionGranularity = "province" | "city";
|
||||
@@ -211,6 +211,8 @@ export type H2BiDrillRecord = Record<
|
||||
>;
|
||||
|
||||
export interface H2BiDrillGroupRow {
|
||||
/** Distinct ledger settlement types for this group; never the UI filter. */
|
||||
settlementTypes?: string | null;
|
||||
[key: string]: string | number | boolean | null | undefined;
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -3794,6 +3794,146 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 8113 原型:真实账本下钻弹层视觉合同。 */
|
||||
.ehb-drill-modal--unified .ehb-modal-body {
|
||||
padding: 18px;
|
||||
background: #f6f8fb;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-drill-root-tabs {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-bar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid #dfe7f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-item {
|
||||
min-width: 0;
|
||||
padding: 12px 16px;
|
||||
border-right: 1px solid #dfe7f0;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-item:last-child { border-right: 0; }
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-label { color: #64748b; }
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-val {
|
||||
color: #172238;
|
||||
font-family: var(--bi-font-mono);
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-filter-row {
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin: 12px 0;
|
||||
border: 1px solid #dfe7f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-filter-group { width: 100%; gap: 8px; }
|
||||
.ehb-drill-modal--unified .ehb-modal-hint-text {
|
||||
flex: 1 1 280px;
|
||||
min-width: 220px;
|
||||
color: #7b8aa1;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-table-wrap {
|
||||
border-color: #d7e1ed;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-table th {
|
||||
height: 44px;
|
||||
padding: 10px 12px;
|
||||
border-bottom-color: #d7e1ed;
|
||||
background: #f0f3f8;
|
||||
color: #52627a;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-table > tbody > tr > td {
|
||||
height: 48px;
|
||||
padding-block: 10px;
|
||||
border-bottom-color: #e5ebf2;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row--station > td { background: #edf3fa; }
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row--customer > td { background: #f5f7fa; }
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row--vehicle > td { background: #fafbfd; }
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row:hover > td { background: #e8f0fb; }
|
||||
|
||||
.ehb-drill-modal--unified .ehb-tree-toggle {
|
||||
min-width: 18px;
|
||||
color: #2f6bff;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-kpi-drill-hint {
|
||||
color: #2f6bff;
|
||||
background: #eaf1ff;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-table td[style*="text-align: right"] {
|
||||
color: #334155;
|
||||
font-family: var(--bi-font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-volume {
|
||||
color: #2f6bff !important;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-income {
|
||||
color: #2c8a78 !important;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-bearer-tag.is-cust {
|
||||
color: #b86606;
|
||||
border: 1px solid #f2d26c;
|
||||
background: #fffbea;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-bearer-tag.is-lingniu {
|
||||
color: #2f6bff;
|
||||
border: 1px solid #bdd0fb;
|
||||
background: #eef4ff;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-bearer-tag.is-other {
|
||||
color: #64748b;
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-tag--source-api,
|
||||
.ehb-drill-modal--unified .ehb-tag--source-station,
|
||||
.ehb-drill-modal--unified .ehb-tag--source-lingniu {
|
||||
border: 1px solid #cbd9e8;
|
||||
background: #eef4f9;
|
||||
color: #526b88;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-bar {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-item:nth-child(2) { border-right: 0; }
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-item:nth-child(-n + 2) { border-bottom: 1px solid #dfe7f0; }
|
||||
}
|
||||
|
||||
.ehb-h5-scroll-hint {
|
||||
display: none;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user