Compare commits
3
Commits
5eb38a4b05
...
637a72c1e9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
637a72c1e9 | ||
|
|
98efde3f75 | ||
|
|
6c91a6694a |
@@ -0,0 +1,12 @@
|
||||
# Copy to .env.local; never commit real credentials. Environment values win.
|
||||
HYDROGEN_DB_HOST=
|
||||
HYDROGEN_DB_PORT=3306
|
||||
HYDROGEN_DB_USER=
|
||||
HYDROGEN_DB_PASSWORD=
|
||||
HYDROGEN_DB_NAME=
|
||||
# Optional legacy/electric business database; use a database-level read-only user.
|
||||
DB_HOST=
|
||||
DB_PORT=3306
|
||||
DB_USER=
|
||||
DB_PASSWORD=
|
||||
DB_NAME=
|
||||
@@ -1,4 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
.worktrees
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# 本地只读预览
|
||||
|
||||
使用 Node.js 22+ 和 npm,在本项目内执行 `npm ci`,避免依赖其他工作目录的 node_modules 软链接。
|
||||
|
||||
将 `.env.local.example` 复制为 `.env.local` 并填写连接配置,或通过进程环境变量提供配置。使用数据库侧只读账号;不要提交真实凭据。
|
||||
|
||||
执行 `npm run dev:local`,浏览器访问 http://127.0.0.1:8115/energy#hydrogen 。前后端固定绑定本机 8115 / 3001;端口占用时退出,不自动换端口。停止终端进程即可停止服务;此命令不配置开机自启。
|
||||
|
||||
本地入口关闭 mock、启用本地免登录,禁用后台建表与定时归档,并拒绝 API 的 POST / PUT / PATCH / DELETE。请勿把免登录预览暴露到公网。氢能查询启用只读 SQL 检查;这不能替代数据库账号的只读权限。
|
||||
|
||||
`npm run dev` / `npm start` 保留原部署方式;设置 `DB_READ_ONLY=1` 时同样不启动后台任务,且 API 禁止写入。生产登录流程需要写请求,因此不要把本地只读模式当作生产部署配置。
|
||||
|
||||
验证:`npm test`、`npm run lint`、`npm run build`;健康检查为 `GET http://127.0.0.1:3001/api/health`。健康检查成功仅代表进程可用,还需检查氢能 meta / overview 的真实数据响应。
|
||||
@@ -4,6 +4,9 @@
|
||||
"version": "1.1.15",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev:local": "concurrently -k -n server,client \"npm run dev:local:server\" \"npm run dev:local:client\"",
|
||||
"dev:local:server": "node --import tsx src/server/local.ts",
|
||||
"dev:local:client": "DEV_MOCK_API=0 VITE_DEV_BYPASS_AUTH=1 vite --host 127.0.0.1 --port 8115 --strictPort",
|
||||
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
|
||||
"dev:server": "tsx watch src/server/index.ts",
|
||||
"dev:client": "vite --port=3000 --host=0.0.0.0",
|
||||
|
||||
+16
@@ -13,6 +13,12 @@ import {
|
||||
|
||||
const EleImportPage = lazy(() => import("./modules/ele/EleImportPage"));
|
||||
const FeedbackAdminPage = lazy(() => import("./modules/admin/FeedbackAdminPage"));
|
||||
const HydrogenPrototypeBoard = lazy(
|
||||
() =>
|
||||
import("./vendor/lnbi-8113-exact/prototypes/energy-h2-bi-board/EnergyBiBoardApp").then(
|
||||
({ EnergyBiBoardApp }) => ({ default: EnergyBiBoardApp }),
|
||||
),
|
||||
);
|
||||
normalizeBrowserPath();
|
||||
|
||||
function AuthGate() {
|
||||
@@ -83,6 +89,16 @@ function AuthGate() {
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
if (routeKey === "energy/hydrogen-board") {
|
||||
if (!canAccessEnergy(user?.roles)) {
|
||||
return <UnauthorizedPage message="无能源管理模块访问权限" />;
|
||||
}
|
||||
return (
|
||||
<Suspense fallback={<LoadingState label="正在加载氢能经营看板" />}>
|
||||
<HydrogenPrototypeBoard />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// /energy 整组按能源权限控制
|
||||
if (pathSet === "energy" && !canAccessEnergy(user?.roles)) {
|
||||
|
||||
@@ -35,4 +35,5 @@ test('keeps energy heatmap visibility and the independent energy navigation', ()
|
||||
buildModules('energy', []).map(module => module.id),
|
||||
['hydrogen', 'electric', 'etc'],
|
||||
);
|
||||
assert.equal(buildModules('energy', [])[0]?.label, '氢费BI');
|
||||
});
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ const SCHEDULING_MODULE: ModuleConfig = {
|
||||
};
|
||||
|
||||
const ENERGY_MODULES: ModuleConfig[] = [
|
||||
{ id: 'hydrogen', label: '氢能', icon: Fuel, component: HydrogenModule },
|
||||
{ id: 'hydrogen', label: '氢费BI', icon: Fuel, component: HydrogenModule },
|
||||
{ id: 'electric', label: '电能', icon: Zap, component: ElectricModule },
|
||||
{ id: 'etc', label: 'ETC', icon: Wallet, component: EtcModule },
|
||||
];
|
||||
|
||||
@@ -9,11 +9,14 @@ test('normalizes legacy asset paths without losing search parameters', () => {
|
||||
test('keeps canonical and hidden administration paths unchanged', () => {
|
||||
assert.equal(resolveCanonicalUrl({ pathname: '/asset', search: '', hash: '#assets' }), null);
|
||||
assert.equal(resolveCanonicalUrl({ pathname: '/admin/feedback', search: '', hash: '' }), null);
|
||||
assert.equal(resolveCanonicalUrl({ pathname: '/energy/hydrogen-board', search: '', hash: '' }), null);
|
||||
});
|
||||
|
||||
test('derives path groups and hidden routes from path or hash', () => {
|
||||
assert.equal(getPathSet('/energy'), 'energy');
|
||||
assert.equal(getPathSet('/energy/hydrogen-board'), 'energy');
|
||||
assert.equal(getPathSet('/asset'), 'asset');
|
||||
assert.equal(getRouteKey('/energy/hydrogen-board', ''), 'energy/hydrogen-board');
|
||||
assert.equal(getRouteKey('/asset', '#/ele/import'), 'ele/import');
|
||||
assert.equal(getRouteKey('/admin/feedback', ''), 'admin/feedback');
|
||||
assert.equal(getRouteKey('/asset', '#mileage'), '');
|
||||
|
||||
+11
-2
@@ -5,7 +5,13 @@ interface BrowserLocation {
|
||||
search: string;
|
||||
hash: string;
|
||||
}
|
||||
const ROOT_PATHS = new Set(['/asset', '/energy', '/ele/import', '/admin/feedback']);
|
||||
const ROOT_PATHS = new Set([
|
||||
'/asset',
|
||||
'/energy',
|
||||
'/energy/hydrogen-board',
|
||||
'/ele/import',
|
||||
'/admin/feedback',
|
||||
]);
|
||||
|
||||
const LEGACY_PATHS: Record<string, { path: string; hash?: string }> = {
|
||||
'/': { path: '/asset' },
|
||||
@@ -31,10 +37,13 @@ export function normalizeBrowserPath(): void {
|
||||
}
|
||||
|
||||
export function getPathSet(pathname: string): PathSet {
|
||||
return pathname === '/energy' ? 'energy' : 'asset';
|
||||
return pathname === '/energy' || pathname === '/energy/hydrogen-board' ? 'energy' : 'asset';
|
||||
}
|
||||
|
||||
export function getRouteKey(pathname: string, hash: string): string {
|
||||
if (pathname === '/energy/hydrogen-board') {
|
||||
return 'energy/hydrogen-board';
|
||||
}
|
||||
if (pathname === '/ele/import' || hash === '#/ele/import' || hash === '#ele/import') {
|
||||
return 'ele/import';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -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,12 +2536,6 @@ 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}
|
||||
@@ -2539,12 +2553,7 @@ export const EnergyBiBoardApp: React.FC = () => {
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="ehb-empty">
|
||||
<div className="ehb-empty__title">
|
||||
正在加载真实氢能数据…
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,9 @@ import type {
|
||||
H2BiDailyResponse,
|
||||
H2BiDailyTreeQuery,
|
||||
H2BiDailyTreeResponse,
|
||||
H2BiDrillReadOptions,
|
||||
H2BiFullDrillOptions,
|
||||
H2BiFullDrillResponse,
|
||||
H2BiDrillQuery,
|
||||
H2BiDrillResponse,
|
||||
H2BiMetaResponse,
|
||||
@@ -20,9 +23,9 @@ function queryString(query: Record<string, unknown>) {
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function request<T>(path: string, query: object = {}) {
|
||||
function request<T>(path: string, query: object = {}, options?: RequestInit) {
|
||||
const qs = queryString(query as Record<string, unknown>);
|
||||
return fetchJson<T>(`${BASE}/${path}${qs ? `?${qs}` : ''}`);
|
||||
return fetchJson<T>(`${BASE}/${path}${qs ? `?${qs}` : ''}`, options);
|
||||
}
|
||||
|
||||
export function fetchH2BiMeta() {
|
||||
@@ -34,13 +37,146 @@ 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) {
|
||||
return request<H2BiDailyTreeResponse>('daily-tree', { date, ...query });
|
||||
}
|
||||
|
||||
export function fetchH2BiDrill(query: H2BiDrillQuery) {
|
||||
return request<H2BiDrillResponse>('drill', query);
|
||||
export async function fetchH2BiDrill(
|
||||
query: H2BiDrillQuery,
|
||||
options?: H2BiDrillReadOptions,
|
||||
) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = Math.min(Math.max(1, query.pageSize ?? 100), 200);
|
||||
const response = await request<H2BiDrillResponse>(
|
||||
'drill',
|
||||
{ ...query, page, pageSize },
|
||||
options,
|
||||
);
|
||||
const itemCount = query.groupBy === 'record'
|
||||
? response.records.length
|
||||
: response.groups.length;
|
||||
// Older servers only set hasMore for record pages. A full page is therefore
|
||||
// also treated as potentially incomplete; the final empty/short page proves
|
||||
// completion without silently dropping grouped rows.
|
||||
return {
|
||||
...response,
|
||||
page: {
|
||||
...response.page,
|
||||
page,
|
||||
pageSize,
|
||||
itemCount,
|
||||
hasMore: Boolean(response.page?.hasMore) || itemCount === pageSize,
|
||||
},
|
||||
} satisfies H2BiDrillResponse;
|
||||
}
|
||||
|
||||
function abortError() {
|
||||
const error = new Error('已取消全量读取');
|
||||
error.name = 'AbortError';
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads every page only after a caller explicitly asks for a complete result.
|
||||
* It never returns a partial collection: server errors, cancellation, and the
|
||||
* page guard all reject before an export can be created.
|
||||
*/
|
||||
export async function fetchAllH2BiDrill(
|
||||
query: Omit<H2BiDrillQuery, 'page' | 'pageSize'>,
|
||||
options: H2BiFullDrillOptions = {},
|
||||
): Promise<H2BiFullDrillResponse> {
|
||||
const pageSize = Math.min(Math.max(1, options.pageSize ?? 200), 200);
|
||||
const maxPages = Math.max(1, options.maxPages ?? 250);
|
||||
const maxRows = Math.max(1, options.maxRows ?? 50_000);
|
||||
const groups: H2BiDrillResponse['groups'] = [];
|
||||
const records: H2BiDrillResponse['records'] = [];
|
||||
let first: H2BiDrillResponse | null = null;
|
||||
|
||||
for (let page = 1; page <= maxPages; page += 1) {
|
||||
if (options.signal?.aborted) throw abortError();
|
||||
const result = await fetchH2BiDrill(
|
||||
{ ...query, page, pageSize },
|
||||
{ signal: options.signal },
|
||||
);
|
||||
if (!first) first = result;
|
||||
groups.push(...result.groups);
|
||||
records.push(...result.records);
|
||||
const itemCount = query.groupBy === 'record' ? records.length : groups.length;
|
||||
if (itemCount > maxRows)
|
||||
throw new Error(`全量读取超过 ${maxRows} 条保护上限,未生成不完整结果;请缩小日期范围后重新导出`);
|
||||
if (!result.page.hasMore) {
|
||||
return {
|
||||
...first,
|
||||
groups,
|
||||
records,
|
||||
page: {
|
||||
...result.page,
|
||||
page: 1,
|
||||
pageSize,
|
||||
itemCount,
|
||||
hasMore: false,
|
||||
pagesRead: page,
|
||||
complete: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
throw new Error(`全量读取超过 ${maxPages} 页保护上限,未生成不完整结果;请缩小日期范围后重新导出`);
|
||||
}
|
||||
|
||||
/** Reusable contract for station-detail consumers that need every raw record. */
|
||||
export function fetchAllH2BiDrillRecords(
|
||||
query: Omit<H2BiDrillQuery, 'groupBy' | 'page' | 'pageSize'>,
|
||||
options?: H2BiFullDrillOptions,
|
||||
) {
|
||||
return fetchAllH2BiDrill({ ...query, groupBy: 'record' }, options);
|
||||
}
|
||||
|
||||
@@ -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,143 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { fetchAllH2BiDrill, fetchAllH2BiDrillRecords } from "./api";
|
||||
import type { H2BiDrillResponse } from "./types";
|
||||
|
||||
const query = {
|
||||
year: 2026,
|
||||
vehicleScope: "all" as const,
|
||||
verifyScope: "all" as const,
|
||||
};
|
||||
|
||||
function response(overrides: Partial<H2BiDrillResponse> = {}): H2BiDrillResponse {
|
||||
return {
|
||||
groupBy: "station",
|
||||
amountScope: "all",
|
||||
filters: {},
|
||||
summary: {},
|
||||
groups: [],
|
||||
records: [],
|
||||
page: { page: 1, pageSize: 2, hasMore: false },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function withFetch(
|
||||
handler: (url: URL) => H2BiDrillResponse | Promise<H2BiDrillResponse>,
|
||||
run: () => Promise<void>,
|
||||
) {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
const url = new URL(String(input), "http://ln-bi.local");
|
||||
return new Response(JSON.stringify(await handler(url)), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
}
|
||||
|
||||
test("完整分组读取跨页,并以短页而非旧服务 hasMore 字段确认结束", async () => {
|
||||
const pages: number[] = [];
|
||||
await withFetch((url) => {
|
||||
const page = Number(url.searchParams.get("page"));
|
||||
pages.push(page);
|
||||
return response({
|
||||
groups: page === 1
|
||||
? ["甲", "乙"].map((name, index) => ({ id: String(index), name, province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 }))
|
||||
: [{ id: "3", name: "丙", province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 }],
|
||||
});
|
||||
}, async () => {
|
||||
const result = await fetchAllH2BiDrill({ ...query, groupBy: "station" }, { pageSize: 2 });
|
||||
assert.deepEqual(result.groups.map((row) => row.name), ["甲", "乙", "丙"]);
|
||||
assert.equal(result.page.complete, true);
|
||||
assert.equal(result.page.pagesRead, 2);
|
||||
});
|
||||
assert.deepEqual(pages, [1, 2]);
|
||||
});
|
||||
|
||||
test("完整记录读取跨页后保留全部订单", async () => {
|
||||
await withFetch((url) => {
|
||||
const page = Number(url.searchParams.get("page"));
|
||||
return response({
|
||||
groupBy: "record",
|
||||
records: page === 1
|
||||
? [{ id: "a" }, { id: "b" }]
|
||||
: [{ id: "c" }],
|
||||
});
|
||||
}, async () => {
|
||||
const result = await fetchAllH2BiDrillRecords(query, { pageSize: 2 });
|
||||
assert.deepEqual(result.records.map((row) => row.id), ["a", "b", "c"]);
|
||||
assert.equal(result.page.hasMore, false);
|
||||
});
|
||||
});
|
||||
|
||||
test("全量读取在中途请求失败时拒绝,不返回部分结果", async () => {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
const page = new URL(String(input), "http://ln-bi.local").searchParams.get("page");
|
||||
if (page === "2") return new Response("failed", { status: 502, statusText: "Bad Gateway" });
|
||||
return new Response(JSON.stringify(response({
|
||||
groups: ["甲", "乙"].map((name, index) => ({ id: String(index), name, province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 })),
|
||||
})), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
await assert.rejects(
|
||||
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { pageSize: 2 }),
|
||||
/API error: 502/,
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("空结果是完整结果而不是加载失败", async () => {
|
||||
await withFetch(() => response(), async () => {
|
||||
const result = await fetchAllH2BiDrill({ ...query, groupBy: "station" });
|
||||
assert.deepEqual(result.groups, []);
|
||||
assert.equal(result.page.pagesRead, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("取消的全量读取不会发起请求或生成部分数据", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await assert.rejects(
|
||||
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { signal: controller.signal }),
|
||||
(error: Error) => error.name === "AbortError",
|
||||
);
|
||||
});
|
||||
|
||||
test("全量读取在第一页完成后也会响应取消,不会请求下一页", async () => {
|
||||
const controller = new AbortController();
|
||||
let calls = 0;
|
||||
await withFetch(() => {
|
||||
calls += 1;
|
||||
controller.abort();
|
||||
return response({
|
||||
groups: ["甲", "乙"].map((name, index) => ({ id: String(index), name, province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 })),
|
||||
});
|
||||
}, async () => {
|
||||
await assert.rejects(
|
||||
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { signal: controller.signal, pageSize: 2 }),
|
||||
(error: Error) => error.name === "AbortError",
|
||||
);
|
||||
});
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test("maxRows 和 maxPages 均拒绝不完整的全量读取", async () => {
|
||||
await withFetch(() => response({
|
||||
groups: ["甲", "乙"].map((name, index) => ({ id: String(index), name, province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 })),
|
||||
}), async () => {
|
||||
await assert.rejects(
|
||||
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { pageSize: 2, maxRows: 1 }),
|
||||
/超过 1 条保护上限/,
|
||||
);
|
||||
await assert.rejects(
|
||||
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { pageSize: 2, maxPages: 1 }),
|
||||
/超过 1 页保护上限/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,687 @@
|
||||
.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; }
|
||||
|
||||
/* Reserve space for pagination instead of clipping it below a fixed-height table. */
|
||||
.ehb-drill-modal--unified .ehb-modal-body:has(.ehb-drill-page-controls) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-body:has(.ehb-drill-page-controls) > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-body:has(.ehb-drill-page-controls) > .ehb-modal-table-wrap {
|
||||
flex: 1 1 auto;
|
||||
min-height: 100px;
|
||||
}
|
||||
.ehb-drill-page-controls {
|
||||
display: grid !important;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.ehb-drill-page-controls .ehb-btn {
|
||||
flex: 0 0 auto;
|
||||
min-width: 72px;
|
||||
width: 72px !important;
|
||||
min-height: 40px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.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, reloadToken, refreshToken, onLoadingChange]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setReloadToken((value) => value + 1);
|
||||
onRefresh();
|
||||
};
|
||||
}, [query]);
|
||||
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;
|
||||
@@ -236,10 +238,37 @@ export interface H2BiDrillResponse {
|
||||
page: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
/** Number of rows returned on this page (groups or records). */
|
||||
itemCount?: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/** Options shared by paged drill reads and explicit full exports. */
|
||||
export interface H2BiDrillReadOptions {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* A deliberately bounded, complete drill read. This is for exports and
|
||||
* dedicated detail views only; normal drill tables should use one page.
|
||||
*/
|
||||
export interface H2BiFullDrillResponse extends H2BiDrillResponse {
|
||||
page: H2BiDrillResponse["page"] & {
|
||||
pagesRead: number;
|
||||
complete: true;
|
||||
};
|
||||
}
|
||||
|
||||
export interface H2BiFullDrillOptions extends H2BiDrillReadOptions {
|
||||
/** Protect the browser from an accidental unbounded export. Default: 250. */
|
||||
maxPages?: number;
|
||||
/** Reject instead of retaining an unexpectedly huge complete result. Default: 50000. */
|
||||
maxRows?: number;
|
||||
/** API page size, capped by the server at 200. Default: 200. */
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface H2BiDailyTreeCustomer {
|
||||
id: number;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { serveStatic } from '@hono/node-server/serve-static';
|
||||
import { Hono } from 'hono';
|
||||
import { readOnlyMiddleware } from './read-only-middleware.js';
|
||||
import { cors } from 'hono/cors';
|
||||
import authRouter from './auth/login.js';
|
||||
import { authMiddleware } from './auth/middleware.js';
|
||||
@@ -20,6 +21,7 @@ export function createApp(): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.use('/api/*', cors());
|
||||
app.use('/api/*', readOnlyMiddleware);
|
||||
|
||||
// 登录接口公开,其余 API 统一经过认证与数据权限检查。
|
||||
app.route('/api/auth', authRouter);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { startMileageBackgroundJobs } from './routes/mileage/index.js';
|
||||
|
||||
/** 启动只应在服务进程中运行的数据库准备和定时任务。 */
|
||||
export function startBackgroundServices(): void {
|
||||
if (process.env.DB_READ_ONLY === '1') return;
|
||||
ensureSchedulingTables().catch((error) => {
|
||||
console.error('scheduling bootstrap error:', error);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
import { assertHydrogenReadOnlySql } from './hydrogen-read-only.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const hydrogenPool = mysql.createPool({
|
||||
const rawHydrogenPool = mysql.createPool({
|
||||
// 氢能账本归属线上业务库。保留专用变量,便于未来拆分独立只读库;
|
||||
// 未配置专用变量时,复用主业务库,避免部署环境误回退到历史数据库地址。
|
||||
host: process.env.HYDROGEN_DB_HOST || process.env.DB_HOST,
|
||||
@@ -16,4 +17,13 @@ const hydrogenPool = mysql.createPool({
|
||||
queueLimit: 0,
|
||||
});
|
||||
|
||||
const rawQuery = rawHydrogenPool.query.bind(rawHydrogenPool) as typeof rawHydrogenPool.query;
|
||||
const hydrogenPool = {
|
||||
query: ((sql: unknown, values?: unknown) => {
|
||||
const statement = typeof sql === 'string' ? sql : String((sql as { sql?: unknown })?.sql ?? '');
|
||||
assertHydrogenReadOnlySql(statement, process.env.HYDROGEN_DB_READ_ONLY === '1');
|
||||
return rawQuery(sql as never, values as never);
|
||||
}) as typeof rawHydrogenPool.query,
|
||||
};
|
||||
|
||||
export default hydrogenPool;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { assertHydrogenReadOnlySql } from './hydrogen-read-only.js';
|
||||
|
||||
test('只读模式允许查询语句', () => {
|
||||
for (const sql of ['SELECT 1', ' SHOW TABLES', 'WITH rows AS (SELECT 1) SELECT * FROM rows', 'EXPLAIN SELECT 1']) {
|
||||
assert.doesNotThrow(() => assertHydrogenReadOnlySql(sql, true));
|
||||
}
|
||||
});
|
||||
|
||||
test('只读模式拒绝数据库写入语句', () => {
|
||||
for (const sql of ['INSERT INTO ledger VALUES (1)', 'UPDATE ledger SET value = 1', 'DELETE FROM ledger', 'ALTER TABLE ledger ADD value INT']) {
|
||||
assert.throws(
|
||||
() => assertHydrogenReadOnlySql(sql, true),
|
||||
/HYDROGEN_DB_READ_ONLY blocks non-read SQL/,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
const READ_ONLY_SQL = /^\s*(SELECT|SHOW|WITH|EXPLAIN)\b/i;
|
||||
|
||||
export function assertHydrogenReadOnlySql(statement: string, enabled: boolean) {
|
||||
if (enabled && !READ_ONLY_SQL.test(statement)) {
|
||||
throw new Error('HYDROGEN_DB_READ_ONLY blocks non-read SQL');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
// Local credentials stay outside version control. Exported environment wins.
|
||||
dotenv.config({ path: '.env.local' });
|
||||
dotenv.config();
|
||||
Object.assign(process.env, {
|
||||
DB_READ_ONLY: '1',
|
||||
HYDROGEN_DB_READ_ONLY: '1',
|
||||
MILEAGE_REPORT_AUTO_ARCHIVE: '0',
|
||||
DEV_BYPASS_AUTH: '1',
|
||||
});
|
||||
|
||||
// Import after configuring the environment: pools/auth read it at module load.
|
||||
const [{ serve }, { createApp }] = await Promise.all([
|
||||
import('@hono/node-server'),
|
||||
import('./app.js'),
|
||||
]);
|
||||
// Intentionally do not call bootstrap: no schema initialization or schedulers.
|
||||
serve({ fetch: createApp().fetch, hostname: '127.0.0.1', port: 3001 }, () => {
|
||||
console.log('Local read-only BI API: http://127.0.0.1:3001');
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Hono } from 'hono';
|
||||
import { readOnlyMiddleware } from './read-only-middleware.js';
|
||||
|
||||
test('read-only preview blocks write handlers but permits reads; normal mode is unchanged', async () => {
|
||||
const previous = process.env.DB_READ_ONLY;
|
||||
try {
|
||||
let calls = 0;
|
||||
const app = new Hono();
|
||||
app.use('*', readOnlyMiddleware);
|
||||
app.all('*', (c) => { calls++; return c.text('ok'); });
|
||||
process.env.DB_READ_ONLY = '1';
|
||||
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
|
||||
assert.equal((await app.request('/api/example', { method })).status, 403);
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
for (const method of ['GET', 'HEAD', 'OPTIONS']) {
|
||||
assert.equal((await app.request('/api/example', { method })).status, 200);
|
||||
}
|
||||
delete process.env.DB_READ_ONLY;
|
||||
assert.equal((await app.request('/api/example', { method: 'POST' })).status, 200);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.DB_READ_ONLY;
|
||||
else process.env.DB_READ_ONLY = previous;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
|
||||
/** Guard preview write endpoints before auth/route handlers can perform work. */
|
||||
export const readOnlyMiddleware: MiddlewareHandler = async (context, next) => {
|
||||
if (process.env.DB_READ_ONLY === '1'
|
||||
&& !['GET', 'HEAD', 'OPTIONS'].includes(context.req.method)) {
|
||||
return context.json({ error: '当前为只读预览环境,不允许写入操作' }, 403);
|
||||
}
|
||||
return next();
|
||||
};
|
||||
@@ -18,7 +18,7 @@ export interface HydrogenBiV2Dependencies {
|
||||
}
|
||||
|
||||
type VehicleScope = "all" | "lingniu" | "external";
|
||||
type VerifyScope = "all" | "verified";
|
||||
type VerifyScope = "all" | "verified" | "unverified";
|
||||
type AmountScope = "all" | "customer" | "company" | "other";
|
||||
type GroupBy = "station" | "customer" | "date" | "vehicle" | "record";
|
||||
type RegionGranularity = "province" | "city";
|
||||
@@ -59,19 +59,9 @@ function endOfMonth(month: string) {
|
||||
return new Date(Date.UTC(year, monthNumber, 0)).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function stationRegionSql(granularity: RegionGranularity) {
|
||||
// new_hydrogen_site is the OneOS station master (453 active stations). The
|
||||
// historical hydrogen_station table only contains 89 records, so it cannot
|
||||
// be used to decide a ledger station's province/city.
|
||||
function stationMasterRegionSql(granularity: RegionGranularity) {
|
||||
const districtColumn = granularity === "province" ? "rs.province" : "rs.city";
|
||||
const stationName = `COALESCE(
|
||||
(SELECT COALESCE(NULLIF(rs.site_short_name, ''), NULLIF(rs.site_name, ''))
|
||||
FROM new_hydrogen_site rs
|
||||
WHERE rs.id = b.station_id AND rs.del_flag = '0'
|
||||
LIMIT 1),
|
||||
b.station_name,
|
||||
''
|
||||
)`;
|
||||
const stationName = "COALESCE(NULLIF(rs.site_short_name, ''), NULLIF(rs.site_name, ''), '')";
|
||||
const fallback =
|
||||
granularity === "province"
|
||||
? `CASE WHEN ${stationName} LIKE '%嘉兴%' OR ${stationName} LIKE '%平湖%' THEN '浙江省'
|
||||
@@ -85,16 +75,7 @@ function stationRegionSql(granularity: RegionGranularity) {
|
||||
WHEN ${stationName} LIKE '%成都%' THEN '成都市'
|
||||
WHEN ${stationName} LIKE '%昆山%' THEN '昆山市'
|
||||
ELSE '未归属区域' END`;
|
||||
return `COALESCE(
|
||||
(SELECT NULLIF(rd.NAME, '')
|
||||
FROM new_hydrogen_site rs
|
||||
LEFT JOIN common_district rd
|
||||
ON CONVERT(rd.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(${districtColumn} USING utf8mb4) COLLATE utf8mb4_unicode_ci
|
||||
AND rd.STATUS = 'VALID'
|
||||
WHERE rs.id = b.station_id AND rs.del_flag = '0'
|
||||
LIMIT 1),
|
||||
${fallback}
|
||||
)`;
|
||||
return `COALESCE(NULLIF(rd.NAME, ''), ${fallback})`;
|
||||
}
|
||||
|
||||
function todayYmd() {
|
||||
@@ -165,7 +146,9 @@ function resolveFilter(query: (key: string) => string | undefined): Filter {
|
||||
? (query("vehicleScope") as VehicleScope)
|
||||
: "all";
|
||||
const verifyScope: VerifyScope =
|
||||
query("verifyScope") === "verified" ? "verified" : "all";
|
||||
query("verifyScope") === "verified" || query("verifyScope") === "unverified"
|
||||
? (query("verifyScope") as VerifyScope)
|
||||
: "all";
|
||||
const clauses = [
|
||||
HYDROGEN_BASE_WHERE_B,
|
||||
`b.${HYDROGEN_LOCAL} >= ?`,
|
||||
@@ -193,16 +176,16 @@ function resolveFilter(query: (key: string) => string | undefined): Filter {
|
||||
clauses.push("COALESCE(NULLIF(b.license_plate, ''), '无车牌') = ?");
|
||||
params.push(plateNo);
|
||||
}
|
||||
if (region) {
|
||||
clauses.push(`${stationRegionSql(regionGranularity)} = ?`);
|
||||
params.push(region);
|
||||
}
|
||||
if (vehicleScope === "lingniu") clauses.push("b.vehicle_id IS NOT NULL");
|
||||
if (vehicleScope === "external") clauses.push("b.vehicle_id IS NULL");
|
||||
if (verifyScope === "verified")
|
||||
clauses.push(
|
||||
"LOWER(COALESCE(NULLIF(TRIM(b.verify_status), ''), 'unverified')) = 'verified'",
|
||||
);
|
||||
if (verifyScope === "unverified")
|
||||
clauses.push(
|
||||
"LOWER(COALESCE(NULLIF(TRIM(b.verify_status), ''), 'unverified')) <> 'verified'",
|
||||
);
|
||||
return {
|
||||
startDate: safeStart,
|
||||
endDate: safeEnd,
|
||||
@@ -221,8 +204,37 @@ function resolveFilter(query: (key: string) => string | undefined): Filter {
|
||||
};
|
||||
}
|
||||
|
||||
function where(filter: Filter) {
|
||||
return filter.clauses.join(" AND ");
|
||||
async function resolvedWhere(
|
||||
hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"],
|
||||
filter: Filter,
|
||||
) {
|
||||
const clauses = [...filter.clauses];
|
||||
const params = [...filter.params];
|
||||
if (filter.region) {
|
||||
// Resolve the small station master once. The old implementation ran two
|
||||
// correlated station/district subqueries for every ledger row and repeated
|
||||
// that work in summary, grouping and record queries.
|
||||
const districtColumn =
|
||||
filter.regionGranularity === "province" ? "rs.province" : "rs.city";
|
||||
const [rows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT CAST(rs.id AS CHAR) AS id
|
||||
FROM new_hydrogen_site rs
|
||||
LEFT JOIN common_district rd
|
||||
ON CONVERT(rd.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(${districtColumn} USING utf8mb4) COLLATE utf8mb4_unicode_ci
|
||||
AND rd.STATUS = 'VALID'
|
||||
WHERE rs.del_flag = '0'
|
||||
AND ${stationMasterRegionSql(filter.regionGranularity)} = ?`,
|
||||
[filter.region],
|
||||
);
|
||||
const stationIds = [...new Set(rows.map((row) => String(row.id)))];
|
||||
if (stationIds.length === 0) {
|
||||
clauses.push("1 = 0");
|
||||
} else {
|
||||
clauses.push(`b.station_id IN (${stationIds.map(() => "?").join(", ")})`);
|
||||
params.push(...stationIds);
|
||||
}
|
||||
}
|
||||
return { sql: clauses.join(" AND "), params };
|
||||
}
|
||||
function filterContext(filter: Filter) {
|
||||
return {
|
||||
@@ -311,6 +323,13 @@ async function meta(hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"]) {
|
||||
LEFT JOIN common_district p ON CONVERT(p.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.province USING utf8mb4) COLLATE utf8mb4_unicode_ci AND p.STATUS = 'VALID'
|
||||
LEFT JOIN common_district ct ON CONVERT(ct.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci AND ct.STATUS = 'VALID'
|
||||
WHERE s.del_flag = '0'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
WHERE b.del_flag = '0'
|
||||
AND b.station_id = s.id
|
||||
AND COALESCE(b.amount_kg, 0) > 0
|
||||
)
|
||||
ORDER BY name`,
|
||||
),
|
||||
hydrogenPool.query<RowDataPacket[]>(
|
||||
@@ -343,7 +362,7 @@ async function overview(
|
||||
hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"],
|
||||
filter: Filter,
|
||||
) {
|
||||
const sqlWhere = where(filter);
|
||||
const { sql: sqlWhere, params } = await resolvedWhere(hydrogenPool, filter);
|
||||
const month = filter.endDate.slice(0, 7);
|
||||
const [summaryRows, monthlyRows, stationRows, customerRows] =
|
||||
await Promise.all([
|
||||
@@ -367,7 +386,7 @@ async function overview(
|
||||
COUNT(DISTINCT COALESCE(b.station_id, 0)) AS stationCount
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
WHERE ${sqlWhere}`,
|
||||
[month, month, filter.endDate, filter.endDate, ...filter.params],
|
||||
[month, month, filter.endDate, filter.endDate, ...params],
|
||||
),
|
||||
hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') AS month,
|
||||
@@ -384,7 +403,7 @@ async function overview(
|
||||
WHERE ${sqlWhere}
|
||||
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m')
|
||||
ORDER BY month`,
|
||||
filter.params,
|
||||
params,
|
||||
),
|
||||
hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT CAST(MAX(COALESCE(b.station_id, 0)) AS CHAR) AS id,
|
||||
@@ -408,8 +427,9 @@ async function overview(
|
||||
LEFT JOIN common_district ct ON CONVERT(ct.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci AND ct.STATUS = 'VALID'
|
||||
WHERE ${sqlWhere}
|
||||
GROUP BY COALESCE(b.station_id, 0)
|
||||
HAVING SUM(COALESCE(b.amount_kg, 0)) > 0
|
||||
ORDER BY kg DESC`,
|
||||
filter.params,
|
||||
params,
|
||||
),
|
||||
hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT COALESCE(b.system_customer_id, b.customer_id, 0) AS id,
|
||||
@@ -430,7 +450,7 @@ async function overview(
|
||||
GROUP BY COALESCE(b.system_customer_id, b.customer_id, 0), COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户')
|
||||
ORDER BY kg DESC
|
||||
LIMIT 200`,
|
||||
filter.params,
|
||||
params,
|
||||
),
|
||||
]);
|
||||
const summary: RowDataPacket = summaryRows[0][0] ?? ({} as RowDataPacket);
|
||||
@@ -575,7 +595,7 @@ async function daily(
|
||||
hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"],
|
||||
filter: Filter,
|
||||
) {
|
||||
const sqlWhere = where(filter);
|
||||
const { sql: sqlWhere, params } = await resolvedWhere(hydrogenPool, filter);
|
||||
const [rows, watermarkRows] = await Promise.all([
|
||||
hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS date,
|
||||
@@ -589,12 +609,12 @@ async function daily(
|
||||
WHERE ${sqlWhere}
|
||||
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')
|
||||
ORDER BY date`,
|
||||
filter.params,
|
||||
params,
|
||||
),
|
||||
hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(MAX(b.${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS ledgerAt
|
||||
FROM ${HYDROGEN_TABLE} b WHERE ${sqlWhere}`,
|
||||
filter.params,
|
||||
params,
|
||||
),
|
||||
]);
|
||||
const map = new Map(rows[0].map((row) => [String(row.date), row]));
|
||||
@@ -647,7 +667,7 @@ async function dailyTree(
|
||||
hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"],
|
||||
filter: Filter,
|
||||
) {
|
||||
const sqlWhere = where(filter);
|
||||
const { sql: sqlWhere, params } = await resolvedWhere(hydrogenPool, filter);
|
||||
const [stationRows, customerRows] = await Promise.all([
|
||||
hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT CAST(MAX(COALESCE(b.station_id, 0)) AS CHAR) AS id,
|
||||
@@ -660,7 +680,7 @@ async function dailyTree(
|
||||
WHERE ${sqlWhere}
|
||||
GROUP BY COALESCE(b.station_id, 0)
|
||||
ORDER BY kg DESC`,
|
||||
filter.params,
|
||||
params,
|
||||
),
|
||||
hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT CAST(MAX(COALESCE(b.station_id, 0)) AS CHAR) AS stationId,
|
||||
@@ -673,7 +693,7 @@ async function dailyTree(
|
||||
WHERE ${sqlWhere}
|
||||
GROUP BY COALESCE(b.station_id, 0), COALESCE(b.system_customer_id, b.customer_id, 0), COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户')
|
||||
ORDER BY stationId, kg DESC`,
|
||||
filter.params,
|
||||
params,
|
||||
),
|
||||
]);
|
||||
const customersByStation = new Map<
|
||||
@@ -719,7 +739,7 @@ async function drill(
|
||||
pageSize: number,
|
||||
amountScope: AmountScope,
|
||||
) {
|
||||
const sqlWhere = where(filter);
|
||||
const { sql: sqlWhere, params } = await resolvedWhere(hydrogenPool, filter);
|
||||
const scopedWhere =
|
||||
amountScope === "customer"
|
||||
? `${sqlWhere} AND ${CUSTOMER_BEARING_ORDER}`
|
||||
@@ -728,7 +748,6 @@ async function drill(
|
||||
: amountScope === "other"
|
||||
? `${sqlWhere} AND ${OTHER_BEARING_ORDER}`
|
||||
: sqlWhere;
|
||||
const params = filter.params;
|
||||
const offset = (page - 1) * pageSize;
|
||||
const groupSelect =
|
||||
groupBy === "station"
|
||||
@@ -746,7 +765,10 @@ async function drill(
|
||||
: groupBy === "date"
|
||||
? `DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')`
|
||||
: "COALESCE(NULLIF(b.license_plate, ''), '无车牌')";
|
||||
const groupOrder = groupBy === "date" ? "id DESC" : "kg DESC";
|
||||
// Stable tie-breakers prevent equal-volume groups drifting between pages.
|
||||
const groupOrder = groupBy === "date" ? "id DESC" : "kg DESC, id ASC, name ASC";
|
||||
const groupHaving =
|
||||
groupBy === "station" ? "HAVING SUM(COALESCE(b.amount_kg, 0)) > 0" : "";
|
||||
const [summaryRows, groupRows, recordRows] = await Promise.all([
|
||||
hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT COUNT(*) AS recordCount,
|
||||
@@ -762,6 +784,7 @@ async function drill(
|
||||
? Promise.resolve([[] as RowDataPacket[]])
|
||||
: hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT ${groupSelect},
|
||||
GROUP_CONCAT(DISTINCT COALESCE(CAST(b.settlement_type AS CHAR), 'unknown') ORDER BY COALESCE(CAST(b.settlement_type AS CHAR), 'unknown')) AS settlementTypes,
|
||||
COUNT(*) AS recordCount, COUNT(DISTINCT COALESCE(b.station_id, 0)) AS stationCount,
|
||||
COUNT(DISTINCT COALESCE(b.system_customer_id, b.customer_id, 0)) AS customerCount,
|
||||
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg, ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost, ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue,
|
||||
@@ -773,11 +796,13 @@ async function drill(
|
||||
LEFT JOIN common_district ct ON CONVERT(ct.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci AND ct.STATUS = 'VALID'
|
||||
WHERE ${scopedWhere}
|
||||
GROUP BY ${groupExpression}
|
||||
${groupHaving}
|
||||
ORDER BY ${groupOrder} LIMIT ? OFFSET ?`,
|
||||
[...params, pageSize, offset],
|
||||
),
|
||||
hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT b.id, DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d %H:%i:%s') AS time, b.order_no AS orderNo,
|
||||
groupBy === "record"
|
||||
? hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT b.id, b.settlement_type AS settlementType, DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d %H:%i:%s') AS time, b.order_no AS orderNo,
|
||||
CAST(COALESCE(b.station_id, 0) AS CHAR) AS stationId, COALESCE(NULLIF(b.station_name, ''), '未关联站点') AS stationName,
|
||||
COALESCE(b.system_customer_id, b.customer_id, 0) AS customerId, COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户') AS customerName,
|
||||
COALESCE(NULLIF(b.license_plate, ''), '无车牌') AS plateNo, COALESCE(NULLIF(b.record_source, ''), CAST(b.source AS CHAR), '未知来源') AS source,
|
||||
@@ -787,7 +812,8 @@ async function drill(
|
||||
FROM ${HYDROGEN_TABLE} b WHERE ${scopedWhere}
|
||||
ORDER BY b.${HYDROGEN_LOCAL} DESC, b.id DESC LIMIT ? OFFSET ?`,
|
||||
[...params, pageSize, offset],
|
||||
),
|
||||
)
|
||||
: Promise.resolve([[] as RowDataPacket[]]),
|
||||
]);
|
||||
const summary = summaryRows[0][0] ?? {};
|
||||
return {
|
||||
@@ -816,8 +842,10 @@ async function drill(
|
||||
revenue: number(row.revenue),
|
||||
lingniuKg: number(row.lingniuKg, 3),
|
||||
externalKg: number(row.externalKg, 3),
|
||||
settlementTypes: row.settlementTypes == null ? null : String(row.settlementTypes),
|
||||
})),
|
||||
records: recordRows[0].map((row) => ({
|
||||
settlementType: row.settlementType == null ? null : String(row.settlementType),
|
||||
id: String(row.id),
|
||||
time: String(row.time),
|
||||
orderNo: String(row.orderNo || ""),
|
||||
@@ -837,7 +865,7 @@ async function drill(
|
||||
cost: number(row.cost),
|
||||
revenue: number(row.revenue),
|
||||
})),
|
||||
page: { page, pageSize, hasMore: recordRows[0].length === pageSize },
|
||||
page: { page, pageSize, hasMore: (groupBy === "record" ? recordRows[0] : groupRows[0]).length === pageSize },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ function numberValue(value: unknown): number {
|
||||
return Number(value) || 0;
|
||||
}
|
||||
|
||||
// 单站经营看板只做只读聚合。站点列表保留区间内零业务站点,便于核对站点覆盖范围。
|
||||
// 单站经营看板只做只读聚合。列表仅返回所选区间内存在有效加氢记录的站点。
|
||||
export function registerHydrogenStationBoardRoute(
|
||||
app: Hono,
|
||||
{ hydrogenPool, cached }: HydrogenStationBoardDependencies,
|
||||
@@ -140,7 +140,9 @@ export function registerHydrogenStationBoardRoute(
|
||||
kg: dailyKgByStation.get(id)?.get(date) ?? 0,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}).filter(station => station.recordCount > 0
|
||||
|| station.name.includes('东鹏大道')
|
||||
|| (station.name.includes('佛山南海') && station.name.includes('羚牛')));
|
||||
|
||||
let selected = null;
|
||||
if (stationId) {
|
||||
|
||||
@@ -115,6 +115,7 @@ test("氢能 BI v2 使用独立真实账本合同,并且不让前端拼接旧
|
||||
assert.equal(calls.length, 3);
|
||||
assert.match(calls[0].sql, /FROM hydrogen_fuel_ledger/);
|
||||
assert.match(calls[1].sql, /FROM new_hydrogen_site/);
|
||||
assert.match(calls[1].sql, /EXISTS\s*\([\s\S]*amount_kg[\s\S]*> 0/);
|
||||
});
|
||||
|
||||
test("氢能 BI v2 下钻在日期、站点、客户、车辆及区域上下文中复用同一过滤口径", async () => {
|
||||
@@ -124,6 +125,7 @@ test("氢能 BI v2 下钻在日期、站点、客户、车辆及区域上下文
|
||||
hydrogenPool: {
|
||||
query: createQueryMock(
|
||||
[
|
||||
[{ id: "123" }],
|
||||
[{ recordCount: 2, kg: 30, cost: 900, revenue: 1000 }],
|
||||
[
|
||||
{
|
||||
@@ -141,25 +143,6 @@ test("氢能 BI v2 下钻在日期、站点、客户、车辆及区域上下文
|
||||
externalKg: 30,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: 9,
|
||||
time: "2026-08-18 08:00:00",
|
||||
orderNo: "H2-9",
|
||||
stationId: 0,
|
||||
stationName: "未关联站点",
|
||||
customerId: 0,
|
||||
customerName: "未关联客户",
|
||||
plateNo: "无车牌",
|
||||
source: "import",
|
||||
verifyStatus: "VERIFIED",
|
||||
vehicleId: null,
|
||||
kg: 30,
|
||||
unitPrice: 30,
|
||||
cost: 900,
|
||||
revenue: 1000,
|
||||
},
|
||||
],
|
||||
],
|
||||
calls,
|
||||
),
|
||||
@@ -182,6 +165,8 @@ test("氢能 BI v2 下钻在日期、站点、客户、车辆及区域上下文
|
||||
assert.equal(payload.summary.kg, 30);
|
||||
|
||||
assert.equal(calls.length, 3);
|
||||
assert.deepEqual(calls[0].params, ["嘉兴市"]);
|
||||
assert.match(calls[0].sql, /FROM new_hydrogen_site rs/);
|
||||
const commonParams = [
|
||||
"2026-08-18",
|
||||
"2026-08-18",
|
||||
@@ -189,27 +174,26 @@ test("氢能 BI v2 下钻在日期、站点、客户、车辆及区域上下文
|
||||
0,
|
||||
"未关联客户",
|
||||
"无车牌",
|
||||
"嘉兴市",
|
||||
"123",
|
||||
];
|
||||
assert.deepEqual(calls[0].params, commonParams);
|
||||
assert.deepEqual((calls[1].params as unknown[]).slice(0, -2), commonParams);
|
||||
assert.deepEqual(calls[1].params, commonParams);
|
||||
assert.deepEqual((calls[2].params as unknown[]).slice(0, -2), commonParams);
|
||||
for (const call of calls) {
|
||||
for (const call of calls.slice(1)) {
|
||||
assert.match(call.sql, /COALESCE\(b\.station_id, 0\) = \?/);
|
||||
assert.match(
|
||||
call.sql,
|
||||
/COALESCE\(b\.system_customer_id, b\.customer_id, 0\) = \?/,
|
||||
);
|
||||
assert.match(call.sql, /COALESCE\(NULLIF\(b\.license_plate/);
|
||||
assert.match(call.sql, /new_hydrogen_site rs/);
|
||||
assert.match(call.sql, /b\.station_id IN \(\?\)/);
|
||||
assert.match(call.sql, /b\.vehicle_id IS NULL/);
|
||||
assert.match(call.sql, /verify_status/);
|
||||
}
|
||||
assert.match(
|
||||
calls[1].sql,
|
||||
calls[2].sql,
|
||||
/GROUP BY DATE_FORMAT\(b\.refuel_time, '%Y-%m-%d'\)/,
|
||||
);
|
||||
assert.match(calls[1].sql, /ORDER BY id DESC/);
|
||||
assert.match(calls[2].sql, /ORDER BY id DESC/);
|
||||
});
|
||||
|
||||
test("氢能 BI v2 利润下钻只保留客户承担订单,并与对客总价使用同一订单集合", async () => {
|
||||
@@ -262,13 +246,16 @@ test("氢能 BI v2 利润下钻只保留客户承担订单,并与对客总价
|
||||
} as unknown as HydrogenBiV2Dependencies);
|
||||
|
||||
const response = await v2App.request(
|
||||
"/h2/v2/drill?year=2026&vehicleScope=all&verifyScope=all&groupBy=station&amountScope=customer",
|
||||
"/h2/v2/drill?year=2026&vehicleScope=all&verifyScope=all&groupBy=station&amountScope=customer&pageSize=1&page=2",
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.amountScope, "customer");
|
||||
assert.equal(payload.page.hasMore, true);
|
||||
assert.deepEqual((calls[1].params as unknown[]).slice(-2), [1, 1]);
|
||||
assert.equal(payload.summary.revenue - payload.summary.cost, 60);
|
||||
assert.equal(calls.length, 3);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.match(calls[1].sql, /ORDER BY kg DESC, id ASC, name ASC LIMIT \? OFFSET \?/);
|
||||
for (const call of calls) {
|
||||
assert.match(
|
||||
call.sql,
|
||||
@@ -473,7 +460,28 @@ test("氢能 BI v2 月度上下文换算为完整自然月并沿用至明细", a
|
||||
]);
|
||||
});
|
||||
|
||||
test("单站看板保留零业务站点并合并区间现结流水", async () => {
|
||||
test("指定单站即使无记录仍可选择,不伪造加氢量", async () => {
|
||||
const app = new Hono();
|
||||
registerHydrogenStationBoardRoute(app, {
|
||||
hydrogenPool: { query: createQueryMock([
|
||||
[
|
||||
{ id: 341, name: "佛山南海羚牛加氢站", kg: 0, fee: 0, recordCount: 0 },
|
||||
{ id: 342, name: "广州交投东鹏大道加氢站", kg: 0, fee: 0, recordCount: 0 },
|
||||
{ id: 343, name: "其他零业务站", kg: 0, fee: 0, recordCount: 0 },
|
||||
], [], [], [], [],
|
||||
], []) },
|
||||
cached: createCachedMock([]),
|
||||
} as unknown as HydrogenStationBoardDependencies);
|
||||
const response = await app.request("/hydrogen/station-board?startDate=2026-08-16&endDate=2026-08-17");
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.deepEqual(payload.stations.map((station: { id: number }) => station.id), [341, 342]);
|
||||
assert.equal(payload.summary.totalKg, 0);
|
||||
assert.equal(payload.summary.activeStationCount, 0);
|
||||
assert.ok(payload.stations.every((station: { dailyKg: { kg: number }[] }) => station.dailyKg.every(row => row.kg === 0)));
|
||||
});
|
||||
|
||||
test("单站看板过滤非指定零业务站并合并区间现结流水", async () => {
|
||||
const calls: QueryCall[] = [];
|
||||
const cacheCalls: CacheCall[] = [];
|
||||
const stationBoardApp = new Hono();
|
||||
@@ -533,7 +541,7 @@ test("单站看板保留零业务站点并合并区间现结流水", async () =>
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.deepEqual(payload.summary, {
|
||||
stationCount: 2,
|
||||
stationCount: 1,
|
||||
activeStationCount: 1,
|
||||
totalKg: 100,
|
||||
totalFee: 3500,
|
||||
@@ -560,8 +568,8 @@ test("单站看板保留零业务站点并合并区间现结流水", async () =>
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(payload.stations[1].name, "零业务站");
|
||||
assert.equal(payload.stations[1].kg, 0);
|
||||
assert.equal(payload.stations.length, 1);
|
||||
assert.equal(payload.stations[0].name, "测试加氢站");
|
||||
assert.deepEqual(payload.stations[0].dailyKg, [
|
||||
{ date: "2026-08-16", kg: 40 },
|
||||
{ date: "2026-08-17", kg: 60 },
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Maximize2 } from 'lucide-react';
|
||||
import './mobile-list-fullscreen.css';
|
||||
|
||||
export function MobileListFullscreenButton({
|
||||
label = '横屏查看',
|
||||
placement = 'overlay',
|
||||
}: {
|
||||
label?: string;
|
||||
placement?: 'overlay' | 'inline';
|
||||
}) {
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
useEffect(() => {
|
||||
const resetTarget = () => {
|
||||
document.querySelectorAll<HTMLElement>('[data-mobile-fullscreen-active="true"]').forEach((item) => {
|
||||
item.removeAttribute('data-mobile-fullscreen-active');
|
||||
item.removeAttribute('data-mobile-fullscreen-mode');
|
||||
item.classList.remove('is-mobile-list-fullscreen', 'is-mobile-list-fallback');
|
||||
});
|
||||
};
|
||||
return () => {
|
||||
document.documentElement.classList.remove('ehb-landscape-session');
|
||||
resetTarget();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const leaveLandscape = (target: HTMLElement) => {
|
||||
target.removeAttribute('data-mobile-fullscreen-active');
|
||||
target.removeAttribute('data-mobile-fullscreen-mode');
|
||||
target.classList.remove('is-mobile-list-fullscreen', 'is-mobile-list-fallback');
|
||||
document.documentElement.classList.remove('ehb-landscape-session');
|
||||
setIsActive(false);
|
||||
};
|
||||
|
||||
const openFullscreen = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
const target = event.currentTarget.closest<HTMLElement>('[data-mobile-fullscreen-list]');
|
||||
if (!target) return;
|
||||
|
||||
if (target.dataset.mobileFullscreenActive === 'true') {
|
||||
leaveLandscape(target);
|
||||
return;
|
||||
}
|
||||
|
||||
target.dataset.mobileFullscreenActive = 'true';
|
||||
target.classList.add('is-mobile-list-fullscreen');
|
||||
document.documentElement.classList.add('ehb-landscape-session');
|
||||
setIsActive(true);
|
||||
|
||||
// 宽表始终在当前方向阅读:竖屏通过表格自身横向滚动,不请求全屏、
|
||||
// 不锁定方向,更不使用 CSS 旋转,避免 Safari 兼容路径把内容挤成窄条。
|
||||
target.dataset.mobileFullscreenMode = 'native-scroll';
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`mobile-list-fullscreen-trigger${placement === 'inline' ? ' is-inline' : ''}`}
|
||||
aria-label={isActive ? '退出完整宽表' : label}
|
||||
aria-pressed={isActive}
|
||||
title={isActive ? '退出宽表模式' : label}
|
||||
onClick={openFullscreen}
|
||||
>
|
||||
<span className="mobile-list-fullscreen-trigger__label">{isActive ? '退出宽表' : '查看完整表格'}</span>
|
||||
<Maximize2 size={15} aria-hidden />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* OneOS 表格下载统一出口:产物一律 .xlsx(禁止 CSV 作为默认/模板路径)。
|
||||
* 上传可另兼容 .xls / 过渡期 .csv;本模块只负责写出 Excel。
|
||||
*/
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
/** @param {string} [name] */
|
||||
export function ensureXlsxFilename(name) {
|
||||
const raw = String(name || 'export').trim() || 'export';
|
||||
const base = raw.replace(/\.(csv|xls|xlsx)$/i, '');
|
||||
return `${base}.xlsx`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown[][]} aoa
|
||||
* @param {string} filename
|
||||
* @param {string} [sheetName]
|
||||
*/
|
||||
export function downloadExcelAoa(aoa, filename, sheetName = 'Sheet1') {
|
||||
const ws = XLSX.utils.aoa_to_sheet(aoa || []);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, sheetName.slice(0, 31) || 'Sheet1');
|
||||
XLSX.writeFile(wb, ensureXlsxFilename(filename));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>[]} rows
|
||||
* @param {string} filename
|
||||
* @param {string} [sheetName]
|
||||
*/
|
||||
export function downloadExcel(rows, filename, sheetName = 'Sheet1') {
|
||||
const ws = XLSX.utils.json_to_sheet(rows || []);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, sheetName.slice(0, 31) || 'Sheet1');
|
||||
XLSX.writeFile(wb, ensureXlsxFilename(filename));
|
||||
}
|
||||
|
||||
/** @deprecated 别名,写出已是 .xlsx */
|
||||
export const downloadXlsAoa = downloadExcelAoa;
|
||||
/** @deprecated 别名,写出已是 .xlsx */
|
||||
export const downloadXls = downloadExcel;
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './types';
|
||||
export * from './store';
|
||||
@@ -0,0 +1,569 @@
|
||||
/**
|
||||
* 体系 A · 站日现结进账 Mock + localStorage
|
||||
* 仅本尊提供 Excel 实站;禁造站。
|
||||
* - 南海:截止8.11 收支汇总 → 现结
|
||||
* - 东鹏大道:加氢记录-20260802 汇总进账明细 → 现结
|
||||
*/
|
||||
import type {
|
||||
CashIntakeChangeLog,
|
||||
StationCashIntakeDay,
|
||||
StationCashIntakeLine,
|
||||
} from './types';
|
||||
|
||||
export const CASH_INTAKE_STORAGE_KEY = 'oneos-energy-spot-cash-intake-v4';
|
||||
const LEGACY_STORAGE_KEYS = [
|
||||
'oneos-energy-spot-cash-intake-v3',
|
||||
'oneos-energy-spot-cash-intake-v2',
|
||||
];
|
||||
|
||||
function seedDays(): StationCashIntakeDay[] {
|
||||
return [
|
||||
{
|
||||
id: 'day-st-fs-nanhai-2026-08-02',
|
||||
stationId: 'st-fs-nanhai',
|
||||
stationName: "佛山南海羚牛加氢站",
|
||||
bizDate: '2026-08-02',
|
||||
totalAmount: 2530.42,
|
||||
lines: [
|
||||
{ id: 'line-st-fs-nanhai-2026-08-02-0', customerName: "东展供应链(广州)有限公司", amount: 655.12, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-02-1', customerName: "广东开鸿氢能科技有限公司", amount: 503.88, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-02-2', customerName: "现代氢能科技有限公司", amount: 760.38, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-02-3', customerName: "广东氢动力科技服务有限公司", amount: 296.78, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-02-4', customerName: "羚牛氢能科技(广东)有限公司", amount: 314.26, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "杨凤娥",
|
||||
updatedAt: '2026-08-02 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-fs-nanhai-2026-08-03',
|
||||
stationId: 'st-fs-nanhai',
|
||||
stationName: "佛山南海羚牛加氢站",
|
||||
bizDate: '2026-08-03',
|
||||
totalAmount: 2193.36,
|
||||
lines: [
|
||||
{ id: 'line-st-fs-nanhai-2026-08-03-0', customerName: "现代氢能科技有限公司", amount: 1259.7, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-03-1', customerName: "羚牛氢能科技(广东)有限公司", amount: 634.22, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-03-2', customerName: "东展供应链(广州)有限公司", amount: 299.44, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "杨凤娥",
|
||||
updatedAt: '2026-08-03 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-fs-nanhai-2026-08-04',
|
||||
stationId: 'st-fs-nanhai',
|
||||
stationName: "佛山南海羚牛加氢站",
|
||||
bizDate: '2026-08-04',
|
||||
totalAmount: 5216.16,
|
||||
lines: [
|
||||
{ id: 'line-st-fs-nanhai-2026-08-04-0', customerName: "广东沣开科技有限公司", amount: 3000.0, payMethod: 'bank_transfer' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-04-1', customerName: "现代氢能科技有限公司", amount: 1024.1, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-04-2', customerName: "羚牛氢能科技(广东)有限公司", amount: 473.86, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-04-3', customerName: "广东氢动力科技服务有限公司", amount: 368.6, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-04-4', customerName: "东展供应链(广州)有限公司", amount: 163.78, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-04-5', customerName: "外省过路车", amount: 185.82, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "杨凤娥",
|
||||
updatedAt: '2026-08-04 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-fs-nanhai-2026-08-05',
|
||||
stationId: 'st-fs-nanhai',
|
||||
stationName: "佛山南海羚牛加氢站",
|
||||
bizDate: '2026-08-05',
|
||||
totalAmount: 3469.78,
|
||||
lines: [
|
||||
{ id: 'line-st-fs-nanhai-2026-08-05-0', customerName: "现代氢能科技有限公司", amount: 1155.58, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-05-1', customerName: "羚牛氢能科技(广东)有限公司", amount: 608.0, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-05-2', customerName: "广东氢动力科技服务有限公司", amount: 530.86, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-05-3', customerName: "广东开鸿氢能科技有限公司", amount: 315.78, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-05-4', customerName: "东展供应链(广州)有限公司", amount: 859.56, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "杨凤娥",
|
||||
updatedAt: '2026-08-05 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-fs-nanhai-2026-08-06',
|
||||
stationId: 'st-fs-nanhai',
|
||||
stationName: "佛山南海羚牛加氢站",
|
||||
bizDate: '2026-08-06',
|
||||
totalAmount: 1006.24,
|
||||
lines: [
|
||||
{ id: 'line-st-fs-nanhai-2026-08-06-0', customerName: "现代氢能科技有限公司", amount: 478.8, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-06-1', customerName: "羚牛氢能科技(广东)有限公司", amount: 527.44, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "杨凤娥",
|
||||
updatedAt: '2026-08-06 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-fs-nanhai-2026-08-07',
|
||||
stationId: 'st-fs-nanhai',
|
||||
stationName: "佛山南海羚牛加氢站",
|
||||
bizDate: '2026-08-07',
|
||||
totalAmount: 22094.18,
|
||||
lines: [
|
||||
{ id: 'line-st-fs-nanhai-2026-08-07-0', customerName: "东展供应链(广州)有限公司", amount: 201.02, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-07-1', customerName: "现代氢能科技有限公司", amount: 1410.18, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-07-2', customerName: "羚牛氢能科技(广东)有限公司", amount: 482.98, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-07-3', customerName: "羚牛氢能科技(广东)有限公司", amount: 20000.0, payMethod: 'bank_transfer' },
|
||||
],
|
||||
updatedBy: "杨凤娥",
|
||||
updatedAt: '2026-08-07 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-fs-nanhai-2026-08-08',
|
||||
stationId: 'st-fs-nanhai',
|
||||
stationName: "佛山南海羚牛加氢站",
|
||||
bizDate: '2026-08-08',
|
||||
totalAmount: 2845.06,
|
||||
lines: [
|
||||
{ id: 'line-st-fs-nanhai-2026-08-08-0', customerName: "东展供应链(广州)有限公司", amount: 745.94, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-08-1', customerName: "现代氢能科技有限公司", amount: 1371.8, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-08-2', customerName: "羚牛氢能科技(广东)有限公司", amount: 727.32, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "杨凤娥",
|
||||
updatedAt: '2026-08-08 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-fs-nanhai-2026-08-09',
|
||||
stationId: 'st-fs-nanhai',
|
||||
stationName: "佛山南海羚牛加氢站",
|
||||
bizDate: '2026-08-09',
|
||||
totalAmount: 2485.58,
|
||||
lines: [
|
||||
{ id: 'line-st-fs-nanhai-2026-08-09-0', customerName: "现代氢能科技有限公司", amount: 775.2, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-09-1', customerName: "羚牛氢能科技(广东)有限公司", amount: 987.62, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-09-2', customerName: "广东氢动力科技服务有限公司", amount: 250.42, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-09-3', customerName: "东展供应链(广州)有限公司", amount: 472.34, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "杨凤娥",
|
||||
updatedAt: '2026-08-09 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-fs-nanhai-2026-08-10',
|
||||
stationId: 'st-fs-nanhai',
|
||||
stationName: "佛山南海羚牛加氢站",
|
||||
bizDate: '2026-08-10',
|
||||
totalAmount: 22823.78,
|
||||
lines: [
|
||||
{ id: 'line-st-fs-nanhai-2026-08-10-0', customerName: "现代氢能科技有限公司", amount: 1582.7, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-10-1', customerName: "羚牛氢能科技(广东)有限公司", amount: 757.34, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-10-2', customerName: "广东氢动力科技服务有限公司", amount: 186.2, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-10-3', customerName: "东展供应链(广州)有限公司", amount: 297.54, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-10-4', customerName: "广东瀚清能源有限公司", amount: 20000.0, payMethod: 'bank_transfer' },
|
||||
],
|
||||
updatedBy: "杨凤娥",
|
||||
updatedAt: '2026-08-10 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-fs-nanhai-2026-08-11',
|
||||
stationId: 'st-fs-nanhai',
|
||||
stationName: "佛山南海羚牛加氢站",
|
||||
bizDate: '2026-08-11',
|
||||
totalAmount: 2301.04,
|
||||
lines: [
|
||||
{ id: 'line-st-fs-nanhai-2026-08-11-0', customerName: "现代氢能科技有限公司", amount: 755.06, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-11-1', customerName: "羚牛氢能科技(广东)有限公司", amount: 596.36, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-11-2', customerName: "广东开鸿氢能科技有限公司", amount: 599.26, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-11-3', customerName: "东展供应链(广州)有限公司", amount: 350.36, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "杨凤娥",
|
||||
updatedAt: '2026-08-11 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-06-26',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-06-26',
|
||||
totalAmount: 10000.0,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-06-26-0', customerName: "广州市梅洛特物流有限公司", amount: 10000.0, payMethod: 'bank_transfer' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-06-26 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-01',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-01',
|
||||
totalAmount: 1506.75,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-01-0', customerName: "广州新运多租赁有限公司", amount: 1506.75, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-01 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-02',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-02',
|
||||
totalAmount: 596.4,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-02-0', customerName: "广州中味餐饮服务有限公司", amount: 96.25, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-02-1', customerName: "广州新运多租赁有限公司", amount: 500.15, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-02 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-03',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-03',
|
||||
totalAmount: 918.4,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-03-0', customerName: "广州新运多租赁有限公司", amount: 918.4, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-03 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-04',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-04',
|
||||
totalAmount: 349.3,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-04-0', customerName: "广州新运多租赁有限公司", amount: 349.3, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-04 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-07',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-07',
|
||||
totalAmount: 20640.15,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-07-0', customerName: "广州市梅洛特物流有限公司", amount: 20000.0, payMethod: 'bank_transfer' },
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-07-1', customerName: "广州中味餐饮服务有限公司", amount: 362.25, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-07-2', customerName: "广州新运多租赁有限公司", amount: 277.9, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-07 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-08',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-08',
|
||||
totalAmount: 896.35,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-08-0', customerName: "广州新运多租赁有限公司", amount: 896.35, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-08 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-09',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-09',
|
||||
totalAmount: 902.65,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-09-0', customerName: "广州中味餐饮服务有限公司", amount: 211.4, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-09-1', customerName: "广州新运多租赁有限公司", amount: 691.25, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-09 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-10',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-10',
|
||||
totalAmount: 1198.05,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-10-0', customerName: "广州中味餐饮服务有限公司", amount: 314.65, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-10-1', customerName: "广州新运多租赁有限公司", amount: 883.4, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-10 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-14',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-14',
|
||||
totalAmount: 266.7,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-14-0', customerName: "广州中味餐饮服务有限公司", amount: 266.7, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-14 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-15',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-15',
|
||||
totalAmount: 562.8,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-15-0', customerName: "广州中味餐饮服务有限公司", amount: 262.5, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-15-1', customerName: "广州新运多租赁有限公司", amount: 300.3, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-15 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-16',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-16',
|
||||
totalAmount: 497.7,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-16-0', customerName: "广州中味餐饮服务有限公司", amount: 200.9, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-16-1', customerName: "广州新运多租赁有限公司", amount: 296.8, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-16 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-17',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-17',
|
||||
totalAmount: 911.4,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-17-0', customerName: "广州新运多租赁有限公司", amount: 911.4, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-17 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-18',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-18',
|
||||
totalAmount: 870.45,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-18-0', customerName: "广州新运多租赁有限公司", amount: 870.45, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-18 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-19',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-19',
|
||||
totalAmount: 468.65,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-19-0', customerName: "广州中味餐饮服务有限公司", amount: 181.3, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-19-1', customerName: "广州新运多租赁有限公司", amount: 287.35, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-19 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-20',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-20',
|
||||
totalAmount: 708.75,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-20-0', customerName: "广州新运多租赁有限公司", amount: 480.55, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-20-1', customerName: "广州中味餐饮服务有限公司", amount: 228.2, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-20 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-21',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-21',
|
||||
totalAmount: 234.85,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-21-0', customerName: "广州新运多租赁有限公司", amount: 234.85, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-21 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-25',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-25',
|
||||
totalAmount: 20686.0,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-25-0', customerName: "广州新运多租赁有限公司", amount: 686.0, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-25-1', customerName: "广州福满华冷链物流有限公司", amount: 20000.0, payMethod: 'bank_transfer' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-25 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-27',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-27',
|
||||
totalAmount: 213.85,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-27-0', customerName: "广州新运多租赁有限公司", amount: 213.85, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-27 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-28',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-28',
|
||||
totalAmount: 20872.2,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-28-0', customerName: "广州中味餐饮服务有限公司", amount: 480.9, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-28-1', customerName: "广州新运多租赁有限公司", amount: 391.3, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-28-2', customerName: "广州市梅洛特物流有限公司", amount: 20000.0, payMethod: 'bank_transfer' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-28 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-29',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-29',
|
||||
totalAmount: 814.8,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-29-0', customerName: "广州中味餐饮服务有限公司", amount: 84.0, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-29-1', customerName: "广州新运多租赁有限公司", amount: 730.8, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-29 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-30',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-30',
|
||||
totalAmount: 1450.4,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-30-0', customerName: "广州中味餐饮服务有限公司", amount: 393.05, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-30-1', customerName: "广州新运多租赁有限公司", amount: 1057.35, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-30 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-31',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-31',
|
||||
totalAmount: 587.65,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-31-0', customerName: "广州新运多租赁有限公司", amount: 587.65, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-31 18:00',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function normalizeDay(d: StationCashIntakeDay): StationCashIntakeDay {
|
||||
return {
|
||||
...d,
|
||||
changeLogs: Array.isArray(d.changeLogs) ? d.changeLogs : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function loadCashIntakeDays(): StationCashIntakeDay[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(CASH_INTAKE_STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as StationCashIntakeDay[];
|
||||
if (Array.isArray(parsed) && parsed.length) return parsed.map(normalizeDay);
|
||||
}
|
||||
for (const key of LEGACY_STORAGE_KEYS) {
|
||||
const legacy = localStorage.getItem(key);
|
||||
if (!legacy) continue;
|
||||
const parsed = JSON.parse(legacy) as StationCashIntakeDay[];
|
||||
if (Array.isArray(parsed) && parsed.length) {
|
||||
const next = parsed.map(normalizeDay);
|
||||
saveCashIntakeDays(next);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return seedDays().map(normalizeDay);
|
||||
}
|
||||
|
||||
export function saveCashIntakeDays(days: StationCashIntakeDay[]): void {
|
||||
try {
|
||||
localStorage.setItem(CASH_INTAKE_STORAGE_KEY, JSON.stringify(days));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function getCashIntakeForStationRange(
|
||||
days: StationCashIntakeDay[],
|
||||
stationId: string,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
): StationCashIntakeDay[] {
|
||||
return days
|
||||
.filter((d) => d.stationId === stationId && d.bizDate >= startDate && d.bizDate <= endDate)
|
||||
.slice()
|
||||
.sort((a, b) => a.bizDate.localeCompare(b.bizDate));
|
||||
}
|
||||
|
||||
export function appendChangeLog(
|
||||
day: StationCashIntakeDay,
|
||||
entry: Omit<CashIntakeChangeLog, 'id'> & { id?: string },
|
||||
): StationCashIntakeDay {
|
||||
const log = {
|
||||
id: entry.id || `log-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
at: entry.at,
|
||||
by: entry.by,
|
||||
action: entry.action,
|
||||
summary: entry.summary,
|
||||
};
|
||||
const prev = Array.isArray(day.changeLogs) ? day.changeLogs : [];
|
||||
return { ...day, changeLogs: [log, ...prev].slice(0, 80) };
|
||||
}
|
||||
|
||||
export function upsertCashIntakeDay(
|
||||
days: StationCashIntakeDay[],
|
||||
next: StationCashIntakeDay,
|
||||
): StationCashIntakeDay[] {
|
||||
const idx = days.findIndex((d) => d.stationId === next.stationId && d.bizDate === next.bizDate);
|
||||
const copy = days.slice();
|
||||
if (idx >= 0) copy[idx] = { ...next, changeLogs: next.changeLogs || copy[idx].changeLogs || [] };
|
||||
else copy.push({ ...next, changeLogs: next.changeLogs || [] });
|
||||
return copy.sort((a, b) => b.bizDate.localeCompare(a.bizDate));
|
||||
}
|
||||
|
||||
export function deleteCashIntakeDay(
|
||||
days: StationCashIntakeDay[],
|
||||
stationId: string,
|
||||
bizDate: string,
|
||||
): StationCashIntakeDay[] {
|
||||
return days.filter((d) => !(d.stationId === stationId && d.bizDate === bizDate));
|
||||
}
|
||||
|
||||
export function sumLines(lines: StationCashIntakeLine[]): number {
|
||||
return lines.reduce((s, l) => s + (Number(l.amount) || 0), 0);
|
||||
}
|
||||
|
||||
export function summarizeCashIntake(
|
||||
days: StationCashIntakeDay[],
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
): { dayCount: number; totalAmount: number; stationCount: number } {
|
||||
const filtered = days.filter((d) => {
|
||||
if (startDate && d.bizDate < startDate) return false;
|
||||
if (endDate && d.bizDate > endDate) return false;
|
||||
return true;
|
||||
});
|
||||
const stations = new Set(filtered.map((d) => d.stationId));
|
||||
return {
|
||||
dayCount: filtered.length,
|
||||
totalAmount: filtered.reduce((s, d) => s + d.totalAmount, 0),
|
||||
stationCount: stations.size,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 站日现结进账类型(共享)
|
||||
* 禁止与预充值能源账户混用(口径见 PRD,不进 UI)。
|
||||
*/
|
||||
|
||||
export type SpotPayMethod = 'wechat_scan' | 'bank_transfer' | 'other';
|
||||
|
||||
export const SPOT_PAY_METHOD_LABEL: Record<SpotPayMethod, string> = {
|
||||
wechat_scan: '微信扫码',
|
||||
bank_transfer: '对公转账',
|
||||
other: '其他',
|
||||
};
|
||||
|
||||
export function parsePayMethodLabel(raw: string): SpotPayMethod | null {
|
||||
const t = String(raw || '').trim();
|
||||
if (!t) return null;
|
||||
const entry = (Object.keys(SPOT_PAY_METHOD_LABEL) as SpotPayMethod[]).find(
|
||||
(k) => SPOT_PAY_METHOD_LABEL[k] === t || k === t,
|
||||
);
|
||||
return entry || null;
|
||||
}
|
||||
|
||||
export interface StationCashIntakeLine {
|
||||
id: string;
|
||||
customerName: string;
|
||||
amount: number;
|
||||
payMethod: SpotPayMethod;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export type CashIntakeChangeAction = 'create' | 'update' | 'delete' | 'import';
|
||||
|
||||
export interface CashIntakeChangeLog {
|
||||
id: string;
|
||||
at: string;
|
||||
by: string;
|
||||
action: CashIntakeChangeAction;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
/** 唯一键 stationId + bizDate(业务字段仍用 bizDate;UI 称「登记日期」) */
|
||||
export interface StationCashIntakeDay {
|
||||
id: string;
|
||||
stationId: string;
|
||||
stationName: string;
|
||||
bizDate: string; // YYYY-MM-DD · 登记日期
|
||||
totalAmount: number;
|
||||
remark?: string;
|
||||
lines: StationCashIntakeLine[];
|
||||
updatedBy: string;
|
||||
updatedAt: string;
|
||||
changeLogs?: CashIntakeChangeLog[];
|
||||
}
|
||||
|
||||
export const CASH_INTAKE_STATIONS = [
|
||||
{ id: 'st-fs-nanhai', name: '佛山南海羚牛加氢站' },
|
||||
{ id: 'st-dp-dongpeng', name: '东鹏大道甲醇制氢一体站' },
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_CASH_STATION_ID = 'st-fs-nanhai';
|
||||
|
||||
/** Make / 本地预览跳转 */
|
||||
export const PROTO_PATH_SPOT_CASH = '/prototypes/energy-spot-cash-intake';
|
||||
export const PROTO_PATH_STATION_DAILY = '/prototypes/energy-h2-station-daily';
|
||||
export const PROTO_PATH_ENERGY_BI = '/prototypes/energy-h2-bi-board';
|
||||
@@ -0,0 +1,385 @@
|
||||
.mobile-list-fullscreen-trigger {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
[data-mobile-fullscreen-list] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mobile-list-fullscreen-trigger {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
display: inline-flex;
|
||||
width: auto;
|
||||
min-width: 124px;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #dbe4f1;
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
color: #fff;
|
||||
background: #2563eb;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 5px 14px rgb(37 99 235 / 20%);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-list-fullscreen-trigger.is-inline {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
z-index: auto;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.mobile-list-fullscreen-trigger__label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile-list-fullscreen-trigger::before {
|
||||
position: absolute;
|
||||
inset: -6px;
|
||||
content: '';
|
||||
}
|
||||
|
||||
[data-mobile-fullscreen-list] > h2,
|
||||
[data-mobile-fullscreen-list] > .sd-section-head,
|
||||
[data-mobile-fullscreen-list] > .sd-panel__head-row,
|
||||
[data-mobile-fullscreen-list] > .ehb-sum-table-card__head,
|
||||
[data-mobile-fullscreen-list] > .ehb-mobile-detail-tabs-head,
|
||||
[data-mobile-fullscreen-list] > .ehb-daily-table-head {
|
||||
box-sizing: border-box;
|
||||
padding-inline-end: 112px !important;
|
||||
}
|
||||
|
||||
/* 复合标题区的 Tab 在第二行,只让第一行标题避让横屏按钮。 */
|
||||
[data-mobile-fullscreen-list] > .ehb-mobile-detail-tabs-head {
|
||||
padding-inline-end: 14px !important;
|
||||
}
|
||||
|
||||
[data-mobile-fullscreen-list] > .ehb-sum-table-card__head .ehb-sum-table-card__meta,
|
||||
[data-mobile-fullscreen-list] > .sd-section-head .sd-panel__meta,
|
||||
[data-mobile-fullscreen-list] > .sd-panel__head-row .sd-panel__meta {
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
[data-mobile-fullscreen-list]:fullscreen,
|
||||
[data-mobile-fullscreen-list].is-mobile-list-fullscreen,
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] {
|
||||
box-sizing: border-box;
|
||||
z-index: 200;
|
||||
overflow: auto;
|
||||
padding: 14px;
|
||||
border-radius: 0;
|
||||
background: #f4f7fb;
|
||||
}
|
||||
|
||||
[data-mobile-fullscreen-list].is-mobile-list-fullscreen,
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] > .mobile-list-fullscreen-trigger,
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] > * > .mobile-list-fullscreen-trigger {
|
||||
position: fixed !important;
|
||||
top: 10px !important;
|
||||
right: 10px !important;
|
||||
z-index: 260 !important;
|
||||
}
|
||||
|
||||
[data-mobile-fullscreen-list]:fullscreen .ehb-sum-table-wrap,
|
||||
[data-mobile-fullscreen-list]:fullscreen .ehb-table-wrap,
|
||||
[data-mobile-fullscreen-list]:fullscreen .sd-table-scroll,
|
||||
[data-mobile-fullscreen-list].is-mobile-list-fullscreen .ehb-sum-table-wrap,
|
||||
[data-mobile-fullscreen-list].is-mobile-list-fullscreen .ehb-table-wrap,
|
||||
[data-mobile-fullscreen-list].is-mobile-list-fullscreen .sd-table-scroll,
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table-wrap,
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-table-wrap,
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .sd-table-scroll {
|
||||
max-height: none;
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* 宽表只切换阅读布局:竖屏保持原方向并由表格横向滚动。 */
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] {
|
||||
box-sizing: border-box;
|
||||
position: fixed !important;
|
||||
z-index: 200 !important;
|
||||
inset: 0 !important;
|
||||
overflow: auto !important;
|
||||
padding: 10px !important;
|
||||
border-radius: 0 !important;
|
||||
background: #f4f7fb !important;
|
||||
}
|
||||
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .mobile-list-fullscreen-trigger {
|
||||
position: fixed !important;
|
||||
z-index: 260 !important;
|
||||
top: 10px !important;
|
||||
right: 10px !important;
|
||||
display: inline-flex !important;
|
||||
width: auto;
|
||||
min-width: 92px;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #dbe4f1;
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
color: #4f6f9f;
|
||||
}
|
||||
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table-wrap,
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-table-wrap,
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .sd-table-scroll {
|
||||
max-height: none !important;
|
||||
overflow: auto !important;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] table {
|
||||
width: max-content !important;
|
||||
min-width: 100% !important;
|
||||
}
|
||||
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] table th:first-child,
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] table td:first-child {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 5;
|
||||
background: #fff;
|
||||
box-shadow: 5px 0 9px -9px #0f172a;
|
||||
}
|
||||
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] table thead th:first-child {
|
||||
z-index: 7;
|
||||
background: #f7f9fc;
|
||||
}
|
||||
|
||||
/* 汇总表的第一列只是序号;继续冻结第二列站点名称,横向滚动时保留行身份。 */
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table th:first-child,
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table td:first-child {
|
||||
width: 42px;
|
||||
min-width: 42px;
|
||||
max-width: 42px;
|
||||
}
|
||||
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table th:nth-child(2),
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table td:nth-child(2) {
|
||||
position: sticky;
|
||||
left: 42px;
|
||||
z-index: 5;
|
||||
min-width: 190px;
|
||||
background: #fff;
|
||||
box-shadow: 7px 0 10px -10px #0f172a;
|
||||
}
|
||||
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table thead th:nth-child(2) {
|
||||
z-index: 7;
|
||||
background: #f7f9fc;
|
||||
}
|
||||
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"]::after {
|
||||
position: fixed;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
z-index: 250;
|
||||
padding: 5px 9px;
|
||||
border: 1px solid #dbe4f1;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 92%);
|
||||
color: #64748b;
|
||||
content: '左右滑动查看更多列';
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 4px 12px rgb(15 23 42 / 8%);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) and (orientation: portrait) {
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"],
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"].is-mobile-list-fallback {
|
||||
transform: none !important;
|
||||
transform-origin: initial !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 下钻弹层最终移动端约束:本文件最后加载,避免旧版宽表规则反向撑开页面。 */
|
||||
@media (max-width: 767px) {
|
||||
.ehb-drill-modal--unified .ehb-modal-body {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
padding: 12px !important;
|
||||
gap: 10px !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-bar {
|
||||
flex: 0 0 auto !important;
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
||||
gap: 6px !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-item {
|
||||
min-width: 0 !important;
|
||||
padding: 10px !important;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-real-drill-filter-summary,
|
||||
.ehb-drill-modal--unified .ehb-drill-filter-summary-row {
|
||||
display: flex !important;
|
||||
flex: 0 0 auto !important;
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.ehb-real-drill-primary-actions {
|
||||
display: grid !important;
|
||||
flex: 0 0 auto;
|
||||
grid-template-columns: minmax(0, 1fr) 132px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ehb-real-drill-primary-actions .ehb-real-drill-filter-summary,
|
||||
.ehb-real-drill-primary-actions .mobile-list-fullscreen-trigger {
|
||||
position: static !important;
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
height: 48px !important;
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
.ehb-real-drill-primary-actions .ehb-real-drill-filter-summary {
|
||||
display: grid !important;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.ehb-real-drill-primary-actions .ehb-real-drill-filter-summary strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-real-drill-filter-summary {
|
||||
min-height: 48px !important;
|
||||
padding: 0 14px !important;
|
||||
border: 1px solid #d9e3f0 !important;
|
||||
border-radius: 12px !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-table-wrap {
|
||||
flex: 1 1 auto !important;
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
min-height: 0 !important;
|
||||
overflow: auto !important;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-drill-filter-summary-row {
|
||||
display: grid !important;
|
||||
grid-template-columns: minmax(0, 1fr) auto !important;
|
||||
align-items: stretch !important;
|
||||
gap: 8px !important;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-drill-filter-summary-row .mobile-list-fullscreen-trigger {
|
||||
position: static !important;
|
||||
min-width: 124px !important;
|
||||
height: 48px !important;
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
.ehb-drill-usage-guide {
|
||||
display: grid !important;
|
||||
flex: 0 0 auto;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
width: 100%;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ehb-drill-usage-guide > span {
|
||||
min-width: 0;
|
||||
padding: 7px 8px;
|
||||
border-radius: 8px;
|
||||
background: #eaf2ff;
|
||||
color: #52637b;
|
||||
font-size: 10px;
|
||||
line-height: 1.35;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ehb-drill-usage-guide strong { color: #1f5fe0; }
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-table tr[aria-expanded] > td:first-child {
|
||||
position: sticky !important;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-table tr[aria-expanded] > td:first-child::after {
|
||||
display: inline-flex;
|
||||
margin-left: 8px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
background: #e8f1ff;
|
||||
color: #2563eb;
|
||||
content: '展开';
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-table tr[aria-expanded="true"] > td:first-child::after {
|
||||
background: #e7f8f1;
|
||||
color: #07875f;
|
||||
content: '收起';
|
||||
}
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified .ehb-modal-body {
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified .ehb-modal-meta-bar {
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified .ehb-modal-table-wrap {
|
||||
flex: 1 1 auto !important;
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
min-height: 0 !important;
|
||||
overflow: auto !important;
|
||||
}
|
||||
Vendored
+41
@@ -0,0 +1,41 @@
|
||||
# 移动端经营总览布局决策
|
||||
|
||||
## 问题
|
||||
|
||||
- 顶部筛选区域占用首屏过多。
|
||||
- 累计加氢量与累计加氢费使用两个大卡片并排,数字和承担结构拥挤。
|
||||
- 利润卡片与本月、本日卡片高度不一致,形成大面积无效留白。
|
||||
- 移动端需要先回答经营结果,再提供费用结构与近期指标。
|
||||
|
||||
## 用户选择
|
||||
|
||||
- 仅优化移动端布局,保留现有数据、筛选和下钻交互。
|
||||
- 使用克制、专业、低饱和的视觉方向。
|
||||
- 以管理层快速查看经营结果、费用结构和近期表现为核心任务。
|
||||
|
||||
## 最终设计决策
|
||||
|
||||
1. 新增单个移动端「经营总览」容器,集中展示累计加氢量、累计加氢费及我司承担、客户承担、待核准三行对照数据。
|
||||
2. 桌面端继续使用原有 KPI 栅格,移动端隐藏原累计量费双卡,避免重复信息。
|
||||
3. 加氢利润改为全宽紧凑卡,本月加氢量与今日加氢量并排呈现。
|
||||
4. 压缩移动端页头、筛选容器和范围提示的垂直空间,不改变筛选状态与即时生效逻辑。
|
||||
5. 保持 44px 最小触控热区、等宽数字及 375px/390px 视口无横向溢出。
|
||||
|
||||
## 参考稿细化确认
|
||||
|
||||
用户追加确认以参考截图优化移动端首屏:
|
||||
|
||||
1. 顶部只保留年份、视图、车辆范围和筛选四项快捷入口,订单范围收进展开筛选。
|
||||
2. 累计经营概览增加我司、客户、待核准三段加氢量构成条,并展示吨数与占比。
|
||||
3. 增加「查看构成」入口,继续复用累计加氢量明细。
|
||||
4. 利润卡改为左侧利润、右侧收入与成本的横向结构。
|
||||
5. 本月与今日指标使用等宽双卡,经营诊断延后至趋势内容之后。
|
||||
|
||||
## 单站页与累计明细补充确认
|
||||
|
||||
1. 单站页把站点数、统计加氢总量、车次、统计金额和现结金额收进一张经营概览,不再把桌面端四卡压成手机两列。
|
||||
2. 日期范围与更新时间保留在同一块紧凑查询区;各站概况改为纵向卡片,竖屏不展示无必要的横屏入口。
|
||||
3. 累计明细顶部改为双主指标:数据归集总量、数据总金额;覆盖站点数和来源完整度降为一行辅助信息。
|
||||
4. 累计明细筛选默认收起为范围摘要,点击后展开完整筛选;竖屏只保留左上返回,不再重复提供关闭和横屏入口。
|
||||
5. 层级数据优先保证站点、客户、车辆与订单摘要在首列可读;详细字段继续在表格内部横向查看,不允许撑宽整页。
|
||||
6. 移动端竖屏明细页头保留左侧返回;站点与客户宽表明细同时保留右侧横屏图标,但不显示重复关闭按钮。业务标题按内容增高并换行,任何入口不得覆盖站点名、客户名或统计时间。
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
# 能源氢费经营看板 · 产品需求说明(PRD)
|
||||
|
||||
> 原型路径:`src/prototypes/energy-h2-bi-board`
|
||||
> 宿主嵌入:`https://bi-next.lnh2e.com/energy#hydrogen/overview`
|
||||
> 宿主视觉单源:`src/resources/prd/energy-bi-host-ref/`
|
||||
> 方案底稿:`src/resources/prd/energy-board-plan-20260806.md`
|
||||
> 口令:`lingniu`(轻门禁 · 本会话记住)
|
||||
> 拍板:嵌入总览 · 我司成本三维度 · 站月/客户汇总表 · **禁 OneOS V2** · D1 无自动解读
|
||||
> **2026-08-12**:顶栏白卡右上角 **全局 / 单站**;单站嵌加氢站日报(起止查询日期自管);单站模式**不显示**看板标题旁时间芯片;现结登记仍独立 OneOS 模块
|
||||
> **2026-08-13**:全局视图**去掉**「加氢站日报 / 站日现结登记」关联入口卡;站日报仅经顶栏「单站」;现结登记走独立模块,本页不再挂跳转摘要
|
||||
|
||||
> 二期:双视角全路径 · 充耗存侧卡 · **预充值能源账户扣款(体系 B)**
|
||||
> 作者:OneOS
|
||||
|
||||
---
|
||||
|
||||
## 0. 嵌入与设计约定
|
||||
|
||||
| 项 | 口径 |
|
||||
|---|---|
|
||||
| 口令 | `lingniu`(轻门禁;本会话 `sessionStorage` 记住) |
|
||||
| 宿主页 | `#hydrogen/overview` |
|
||||
| 功能 | 独立嵌入块「我司成本」:三维度 + 两张汇总表 + 订单明细;**单站**嵌加氢站日报 |
|
||||
| 设计 | 只跟 bi-next 能源 BI;禁止 `V2*`;字体对齐 V2 §2.2 |
|
||||
|
||||
### 视图决策
|
||||
|
||||
| 决策 | 结论 | 理由 |
|
||||
|---|---|---|
|
||||
| 顶栏范围 | **全局 / 单站**(白卡右上角) | 单站回嵌经营看板 |
|
||||
| 全局内视图 | `按日` / `总览` | 原看板能力保留 |
|
||||
| 总览筛选 | 默认**全部车辆**;可切**仅羚牛车辆** / **仅外部车辆**;与年份、全量订单/仅已核对联动 | 外层筛选与 KPI / 图表 / 钻取同一口径 |
|
||||
| 承担方式 | **我司承担 / 客户承担 / 待核准**三类;「自行」并入「客户承担」 | 首页拆分、穿透筛选、明细列和导出口径一致 |
|
||||
| 核心 KPI | PC 展示全部 5 项;移动端前 2 项突出,其余 3 项紧凑保留且均可点击穿透 | 不隐藏旧版指标能力 |
|
||||
| 单站形态 | 加氢站日报:起止查询日期 + KPI 钻取 + 各站单卡(行内加氢量占比 · 按量降序) | 一眼可查 |
|
||||
| 单站时间 | **不展示**看板标题旁「统计时间范围」芯片 | 与全局统计维度不同 |
|
||||
| 单站明细 | 日汇总、区间趋势、客户月量/费、收支、现结;导出取证 `.xlsx` | 查看与取证 |
|
||||
| 现结登记 | **独立** `energy-spot-cash-intake`;本页不办理 | 1C 手维入口唯一 |
|
||||
| 预充值能源账户 | **二期 · 体系 B** | 与现结完全独立 |
|
||||
|
||||
### 关联独立模块
|
||||
|
||||
| 模块 | 路径 | 本看板角色 |
|
||||
|---|---|---|
|
||||
| 加氢站日报 | `energy-h2-station-daily` | 顶栏「单站」嵌入;可独立外链;**不**再挂全局关联卡 |
|
||||
| 站日现结登记 | `energy-spot-cash-intake` | 独立模块办理;本页**不**挂跳转入口与近窗摘要 |
|
||||
| 加氢客户(外部) | `energy-h2-external-customer` | 外部客户主数据;与租赁客户隔离 |
|
||||
| 外部车辆明细 | `energy-h2-external-vehicle` | 外部车牌主数据 + 导入;API 分流「仅外部」数据源 |
|
||||
|
||||
> **2026-08-13 分流口径:** 南海站 API 流水命中外部车辆台账 → `fleetCategory=external`,**只进本看板/站日报**,不进车辆氢费明细;命中自有/租赁车队 → 进氢费明细并可核对。主数据种子车牌与 `mockDaily` 外部牌对齐(见 `src/common/energy-h2-external-fleet`)。
|
||||
|
||||
---
|
||||
|
||||
## 0.1 现结 ≠ 预充值(双体系硬隔离)
|
||||
|
||||
| | **体系 A · 现结** | **体系 B · 预充值(以后)** |
|
||||
|---|---|---|
|
||||
| 一句话 | 到站加完氢 → 当场在线支付 | 预充进账户 → 加氢扣款 |
|
||||
| 本期 | 独立模块手维;看板/站日报只读 | **不做** |
|
||||
| 禁止 | 「账户充值」指现结;订单金额冒充现结 | 用现结台账冒充账户流水 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 模块定位
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 名称 | 能源氢费经营看板(嵌入:我司成本) |
|
||||
| 核心任务 | 全局:三维度结构 → 站月/客户汇总 → 订单解释;单站:站日经营读数与取证 |
|
||||
| 不做 | 现结登记表单;体系 B |
|
||||
|
||||
---
|
||||
|
||||
## 2. 我司成本三维度(仅全局)
|
||||
|
||||
| 维度 | 二级 |
|
||||
|---|---|
|
||||
| 租赁成本 | 我司承担 · 包氢项目 |
|
||||
| 物流成本 | — |
|
||||
| 运维成本 | 异动 · 调拨 |
|
||||
|
||||
客户承担不进三维度。核对 ≠ 对账。
|
||||
|
||||
---
|
||||
|
||||
## 3. 用户故事
|
||||
|
||||
### 经营看板 · 全局
|
||||
|
||||
- **起点:** 打开看板(口令后)→ 顶栏「全局」→ 按日 / 总览。
|
||||
- **怎么运作:** 看我司成本与汇总;站日报经顶栏「单站」进入。
|
||||
- **闭环:** 本页解释成本结构。
|
||||
|
||||
### 经营看板 · 单站
|
||||
|
||||
- **起点:** 顶栏切「单站」→ 驾驶舱各站卡片。
|
||||
- **怎么运作:** 点站点进入汇报口径明细(日汇总/趋势/客户月量费/收支/车辆/现结);可导出取证 Excel。
|
||||
- **闭环:** 数字可查、明细可追到车与现结笔;登记仍去现结模块。
|
||||
|
||||
---
|
||||
|
||||
## 4. KPI 穿透表 · 类型标签口径
|
||||
|
||||
穿透钻取表(加氢站 → 客户 → 车辆):
|
||||
|
||||
表头支持「**按加氢站**」与「**按客户**」两种组织方式;切换只改变层级顺序,均可下钻到车辆和单笔订单。
|
||||
|
||||
| 层级 | 类型 / 归属列 |
|
||||
|---|---|
|
||||
| 加氢站 | **不展示**自用消费 / 对外销售(站侧不再分自用与对外) |
|
||||
| 客户 | **不展示**内部客户 / 外部客户标签 |
|
||||
| 车辆 | 无法识别车牌 → 名称统一「无车牌」(与有牌车辆同级),标签「外部车辆」;能识别且为羚牛车队 → 标签「羚牛车辆」;能识别的外部车 → 标签「外部车辆」 |
|
||||
| 车辆下订单行 | 展示标签「订单编号」+ 单号;字段口径为**加氢订单编号**(导出列亦用「订单编号」) |
|
||||
| 加氢站核对状态 | 按下属羚牛车辆汇总:**已核对**(全部已核)/ **部分核对**(有已核也有未核,或任一带部分)/ **未核对**(一条未核);无参与核对车辆时显示 `-` |
|
||||
| 穿透筛 | **加氢站 / 客户 / 车辆**可搜索选择器(级联)+ **车辆归属**单选按钮组(全部 / 仅羚牛 / 仅外部)+ **承担方式**(全部 / 我司承担 / 客户承担 / 待核准);禁 V2 |
|
||||
| 加氢利润穿透 | 关键列展示**收入 / 成本 / 利润**;顶栏汇总亦为收入·成本·利润 |
|
||||
| 本月加氢穿透 | 关键列展示**本月加氢量 / 本月加氢费 / 加氢费占年比** |
|
||||
| 本日加氢穿透 | 关键列展示**本日加氢量 / 本日加氢费 / 加氢费占月比** |
|
||||
| 月度柱钻取 | 点月度加氢量柱 → 该月**各站**内部客户加氢总量 / 外部客户加氢总量 / 合计 |
|
||||
| 月度收支柱钻取 | 点**客户收入**柱 → 该月各站客户收入;点**成本支出**柱 → 该月各站成本支出 |
|
||||
| Top5 站横条 | 点条 → 该站**内部/外部客户加氢总量**(表可竖滚) |
|
||||
| 区域环图/图例 | 点市或省 → 该区域**各站加氢总量与占比**(表可竖滚) |
|
||||
| 站汇总表钻取 | 默认按日展示:**日期 / 车辆数与加氢笔数 / 加氢量 / 较前日增减 / 氢费收入 / 平均单价**;展开后展示客户、车牌、车辆归属、加氢时间和单笔明细 |
|
||||
| 客户汇总表钻取 | 关键字段:**承担方 / 加氢量 / 成本支出 / 应收 / 已收 / 未收**(按日展开) |
|
||||
| 头部加氢站占比 | 点击展开**加氢量排名**下拉(高→低、可滚动);点站可进穿透 |
|
||||
| 按日经营指标 | 默认展示**区间加氢量 / 日均加氢量 / 较上一周期 / 活跃加氢站**;活跃站点按“当前有记录站点 / 全网络站点”展示并给出覆盖率;车辆构成降级为筛选区间说明,不占主指标卡 |
|
||||
| 全局时间范围 | 每个页面、列表、全屏明细与钻取弹窗必须展示明确的开始与结束时间;**经营汇总到天、当日实时到分钟、订单与加氢流水到秒**;“本日 / 本月 / 年度”等业务名称不可替代具体时间范围 |
|
||||
| 穿透标签提示 | 车辆归属 / 数据来源 / 核对状态标签均有悬浮说明 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 验收
|
||||
|
||||
1. 顶栏白卡右上角有 **全局 / 单站**;全局下仍有按日/总览。
|
||||
2. 单站:站点卡片 → 明细含汇报 Excel 对应区块;导出 `.xlsx`。
|
||||
3. 空态不填 0;现结 ≠ 预充值。
|
||||
4. 口令 `lingniu`;标注壳在门外。
|
||||
5. 禁 OneOS V2 控件。
|
||||
6. KPI 穿透:站/客户无自用·内外标签;无车牌归「无车牌」+「外部车辆」。
|
||||
7. 总览顶栏:默认全部车辆;切仅羚牛/仅外部/年份/核对后,KPI、图表、钻取数同步变化。
|
||||
8. 承担方式仅有我司承担、客户承担、待核准三类;不再单列「自行」,且首页、筛选、明细、导出一致。
|
||||
9. KPI 穿透可按加氢站或客户组织,两种方式都可追到车辆和单笔订单。
|
||||
10. PC 可见 5 项核心 KPI;移动端 5 项均可见、可点击,前 2 项保持主视觉层级。
|
||||
|
||||
---
|
||||
|
||||
## 6. 明确不做(本期)
|
||||
|
||||
- 体系 B 预充值账户
|
||||
- 现结在本页办理
|
||||
- 云效建单(默认不上)
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
# 氢能经营看板开发交付说明
|
||||
|
||||
## 页面入口
|
||||
|
||||
- 本地地址:`http://127.0.0.1:51720/prototypes/energy-h2-bi-board`
|
||||
- 访问口令:`lingniu`
|
||||
- 启动方式:在项目目录执行 `npm ci`,再执行 `npm run dev`
|
||||
|
||||
## 本次交付范围
|
||||
|
||||
- PC 与移动端全局经营总览、日期视图、单站视角。
|
||||
- 年份、日期区间、车辆归属、订单范围、站点、客户等筛选交互。
|
||||
- KPI 下钻、表格逐级展开、Tab 切换、更多数据、横屏查看与退出。
|
||||
- 单站经营明细(日加氢、客户月度、收支、进账)与日期查询。
|
||||
- Excel 导出入口及前端下载逻辑。
|
||||
|
||||
## 数据与接口边界
|
||||
|
||||
- 当前为可运行原型,经营数据来自 `data/mockBoard.ts`、`data/mockDaily.ts` 和单站模块的 `data/mockStationDaily.ts`。
|
||||
- 正式开发需将模拟数据替换为接口返回值,但应保留现有筛选、空状态、下钻层级、双端响应式布局与横屏交互。
|
||||
- 查询条件为即时生效;日期弹层点击“确定”后更新页面统计范围。
|
||||
|
||||
## 验证命令
|
||||
|
||||
```bash
|
||||
npx vitest run tests/energy-h2-bi-board.ui.test.ts tests/energy-h2-bearer-model.test.ts tests/energy-h2-dual-end-interaction.test.ts tests/energy-h2-mobile-kpi-tone.test.ts tests/energy-h2-mobile-layout-polish.test.ts tests/energy-bi-board.visual.test.ts tests/common/EnergyBiBoardMobileLayout.test.ts tests/common/MobileListFullscreenButton.test.tsx
|
||||
ENTRY_KEY=prototypes/energy-h2-bi-board npx vite build
|
||||
```
|
||||
|
||||
交付前结果:8 个测试文件、97 项检查全部通过,目标页面生产构建通过。
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import React, { useState } from 'react';
|
||||
import './styles/energy-bi-board.css';
|
||||
|
||||
export const ENERGY_BI_PASSWORD = 'lingniu';
|
||||
export const ENERGY_BI_AUTH_KEY = 'energy-h2-bi-board-auth-v1';
|
||||
|
||||
export function isEnergyBiAuthed(): boolean {
|
||||
try {
|
||||
return sessionStorage.getItem(ENERGY_BI_AUTH_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setEnergyBiAuthed(ok: boolean): void {
|
||||
try {
|
||||
if (ok) sessionStorage.setItem(ENERGY_BI_AUTH_KEY, '1');
|
||||
else sessionStorage.removeItem(ENERGY_BI_AUTH_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
interface EnergyBiAccessGateProps {
|
||||
onOk: () => void;
|
||||
}
|
||||
|
||||
/** 轻门禁:口令 lingniu · 本会话记住(与汇报舱 / 作战室同口径) */
|
||||
export const EnergyBiAccessGate: React.FC<EnergyBiAccessGateProps> = ({ onOk }) => {
|
||||
const [pwd, setPwd] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (pwd.trim() === ENERGY_BI_PASSWORD) {
|
||||
setEnergyBiAuthed(true);
|
||||
setErr('');
|
||||
onOk();
|
||||
return;
|
||||
}
|
||||
setErr('口令不对,请重试');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ehb-gate">
|
||||
<form className="ehb-gate-card" onSubmit={submit}>
|
||||
<p className="ehb-gate-kicker">ONEOS · 能源 BI</p>
|
||||
<h1 className="ehb-gate-title">氢能经营看板</h1>
|
||||
<p className="ehb-gate-sub">我司成本 · 按日 / 总览 · 单站日报</p>
|
||||
<label className="ehb-gate-label" htmlFor="ehb-pwd">
|
||||
访问口令
|
||||
</label>
|
||||
<input
|
||||
id="ehb-pwd"
|
||||
className="ehb-gate-input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
value={pwd}
|
||||
onChange={(e) => {
|
||||
setPwd(e.target.value);
|
||||
if (err) setErr('');
|
||||
}}
|
||||
placeholder="请输入口令"
|
||||
/>
|
||||
<p className="ehb-gate-error" role="alert">
|
||||
{err}
|
||||
</p>
|
||||
<button type="submit" className="ehb-gate-btn">
|
||||
进入看板
|
||||
</button>
|
||||
<p className="ehb-gate-foot">内部文件 · 请勿外传</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+6556
File diff suppressed because it is too large
Load Diff
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"directory": {
|
||||
"title": "能源氢费经营看板",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "overview",
|
||||
"title": "产品需求说明(PRD)",
|
||||
"type": "markdown",
|
||||
"path": ".spec/requirements-prd.md",
|
||||
"description": "口令 lingniu · 顶栏全局/单站 · 无关联入口卡 · 默认全部车辆·KPI/图表/钻取跟随筛选 · 无车牌归外部车辆 · 外部主数据见 energy-h2-external-* · 分流 own→氢费明细 external→仅 BI"
|
||||
},
|
||||
{
|
||||
"id": "board-app",
|
||||
"title": "氢能经营看板",
|
||||
"type": "route",
|
||||
"path": "/prototypes/energy-h2-bi-board",
|
||||
"description": "顶栏全局/单站;责任主体统一为我司承担、客户承担、待核准三类,自行并入客户承担;PC 展示五项 KPI,移动端五项均可见可点;KPI 穿透支持按加氢站/按客户切换并追到车辆和订单;所有页面、全屏明细和钻取弹窗明确展示开始与结束时间,经营汇总到天、当日实时到分钟、订单和加氢流水到秒"
|
||||
},
|
||||
{
|
||||
"id": "energy-board-plan",
|
||||
"title": "能源 BI 看板方案",
|
||||
"type": "markdown",
|
||||
"path": "../../resources/prd/energy-board-plan-20260806.md"
|
||||
},
|
||||
{
|
||||
"id": "host-ref",
|
||||
"title": "宿主 overview 视觉参考",
|
||||
"type": "markdown",
|
||||
"path": "../../resources/prd/energy-bi-host-ref/content.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import type {
|
||||
CostDim,
|
||||
FleetScope,
|
||||
H2OrderRow,
|
||||
LeaseKind,
|
||||
OpsKind,
|
||||
} from '../types';
|
||||
import { BORNE_BY_LABEL, BORNE_BY_ORDER, COST_DIM_LABEL, LEASE_KIND_LABEL, OPS_KIND_LABEL } from '../types';
|
||||
|
||||
export function filterOrders(
|
||||
rows: H2OrderRow[],
|
||||
year: number,
|
||||
verifyScope: 'all' | 'verified',
|
||||
fleetScope: FleetScope,
|
||||
): H2OrderRow[] {
|
||||
return rows.filter((r) => {
|
||||
if (!r.occurredAt.startsWith(String(year))) return false;
|
||||
if (verifyScope === 'verified' && r.verifyStatus !== 'verified') return false;
|
||||
if (fleetScope === 'own' && r.fleet !== 'own') return false;
|
||||
if (fleetScope === 'external' && r.fleet !== 'external') return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function sumAmount(rows: H2OrderRow[]): number {
|
||||
return rows.reduce((s, r) => s + r.amount, 0);
|
||||
}
|
||||
|
||||
export function sumKg(rows: H2OrderRow[]): number {
|
||||
return rows.reduce((s, r) => s + r.quantityKg, 0);
|
||||
}
|
||||
|
||||
export function companyCostRows(rows: H2OrderRow[]): H2OrderRow[] {
|
||||
return rows.filter((r) => r.borneBy === 'company');
|
||||
}
|
||||
|
||||
export function dimAmount(rows: H2OrderRow[], dim: CostDim): number {
|
||||
return sumAmount(companyCostRows(rows).filter((r) => r.costDim === dim));
|
||||
}
|
||||
|
||||
export function leaseSubAmount(rows: H2OrderRow[], kind: LeaseKind): number {
|
||||
return sumAmount(
|
||||
companyCostRows(rows).filter((r) => r.costDim === 'lease' && r.leaseKind === kind),
|
||||
);
|
||||
}
|
||||
|
||||
export function opsSubAmount(rows: H2OrderRow[], kind: OpsKind): number {
|
||||
return sumAmount(
|
||||
companyCostRows(rows).filter((r) => r.costDim === 'ops' && r.opsKind === kind),
|
||||
);
|
||||
}
|
||||
|
||||
export interface DimCard {
|
||||
key: CostDim;
|
||||
label: string;
|
||||
amount: number;
|
||||
subs: { key: string; label: string; amount: number }[];
|
||||
}
|
||||
|
||||
export function costDimCards(rows: H2OrderRow[]): DimCard[] {
|
||||
return [
|
||||
{
|
||||
key: 'lease',
|
||||
label: COST_DIM_LABEL.lease,
|
||||
amount: dimAmount(rows, 'lease'),
|
||||
subs: [
|
||||
{ key: 'company_borne', label: LEASE_KIND_LABEL.company_borne, amount: leaseSubAmount(rows, 'company_borne') },
|
||||
{ key: 'package_h2', label: LEASE_KIND_LABEL.package_h2, amount: leaseSubAmount(rows, 'package_h2') },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'logistics',
|
||||
label: COST_DIM_LABEL.logistics,
|
||||
amount: dimAmount(rows, 'logistics'),
|
||||
subs: [],
|
||||
},
|
||||
{
|
||||
key: 'ops',
|
||||
label: COST_DIM_LABEL.ops,
|
||||
amount: dimAmount(rows, 'ops'),
|
||||
subs: [
|
||||
{ key: 'abnormal', label: OPS_KIND_LABEL.abnormal, amount: opsSubAmount(rows, 'abnormal') },
|
||||
{ key: 'transfer', label: OPS_KIND_LABEL.transfer, amount: opsSubAmount(rows, 'transfer') },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function pendingAmount(rows: H2OrderRow[]): number {
|
||||
return dimAmount(rows, 'pending');
|
||||
}
|
||||
|
||||
export function unverified(rows: H2OrderRow[]): { amount: number; count: number } {
|
||||
const list = rows.filter((r) => r.verifyStatus === 'unverified');
|
||||
return { amount: sumAmount(list), count: list.length };
|
||||
}
|
||||
|
||||
export function formatYuan(n: number): string {
|
||||
return `¥${n.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`;
|
||||
}
|
||||
|
||||
export function formatKg(n: number): string {
|
||||
return `${n.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} kg`;
|
||||
}
|
||||
|
||||
export function costDimLabel(row: H2OrderRow): string {
|
||||
if (row.costDim === 'lease' && row.leaseKind) {
|
||||
return `${COST_DIM_LABEL.lease} · ${LEASE_KIND_LABEL[row.leaseKind]}`;
|
||||
}
|
||||
if (row.costDim === 'ops' && row.opsKind) {
|
||||
return `${COST_DIM_LABEL.ops} · ${OPS_KIND_LABEL[row.opsKind]}`;
|
||||
}
|
||||
return COST_DIM_LABEL[row.costDim];
|
||||
}
|
||||
|
||||
export type DimFilter =
|
||||
| { dim: CostDim; sub?: string }
|
||||
| null;
|
||||
|
||||
export function applyDimFilter(rows: H2OrderRow[], filter: DimFilter): H2OrderRow[] {
|
||||
if (!filter) return rows;
|
||||
return rows.filter((r) => {
|
||||
if (r.borneBy !== 'company') return false;
|
||||
if (r.costDim !== filter.dim) return false;
|
||||
if (!filter.sub) return true;
|
||||
if (filter.dim === 'lease') return r.leaseKind === filter.sub;
|
||||
if (filter.dim === 'ops') return r.opsKind === filter.sub;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** 统计/明细共用:维度筛后的我司成本行;无维度筛则全部我司行 */
|
||||
export function companyRowsForStats(rows: H2OrderRow[], filter: DimFilter): H2OrderRow[] {
|
||||
if (!filter) return companyCostRows(rows);
|
||||
return applyDimFilter(rows, filter);
|
||||
}
|
||||
|
||||
export interface StationMonthRow {
|
||||
stationId: string;
|
||||
stationName: string;
|
||||
month: string;
|
||||
amount: number;
|
||||
quantityKg: number;
|
||||
unverifiedAmount: number;
|
||||
}
|
||||
|
||||
export function stationMonthAgg(rows: H2OrderRow[]): StationMonthRow[] {
|
||||
const map = new Map<string, H2OrderRow[]>();
|
||||
rows.forEach((r) => {
|
||||
const month = r.occurredAt.slice(0, 7);
|
||||
const key = `${r.stationId}|${month}`;
|
||||
const list = map.get(key) ?? [];
|
||||
list.push(r);
|
||||
map.set(key, list);
|
||||
});
|
||||
return Array.from(map.entries())
|
||||
.map(([key, list]) => {
|
||||
const [stationId, month] = key.split('|');
|
||||
return {
|
||||
stationId,
|
||||
stationName: list[0].stationName,
|
||||
month,
|
||||
amount: sumAmount(list),
|
||||
quantityKg: sumKg(list),
|
||||
unverifiedAmount: sumAmount(list.filter((x) => x.verifyStatus === 'unverified')),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.amount - a.amount);
|
||||
}
|
||||
|
||||
export interface CustomerAttrRow {
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
borneLabel: string;
|
||||
quantityKg: number;
|
||||
companyCost: number;
|
||||
unverifiedAmount: number;
|
||||
}
|
||||
|
||||
export function customerAttrAgg(rows: H2OrderRow[]): CustomerAttrRow[] {
|
||||
const map = new Map<string, H2OrderRow[]>();
|
||||
rows.forEach((r) => {
|
||||
const list = map.get(r.customerId) ?? [];
|
||||
list.push(r);
|
||||
map.set(r.customerId, list);
|
||||
});
|
||||
return Array.from(map.entries())
|
||||
.map(([customerId, list]) => {
|
||||
const company = list.filter((x) => x.borneBy === 'company');
|
||||
const activeBorneTypes = BORNE_BY_ORDER.filter((borneBy) => list.some((x) => x.borneBy === borneBy));
|
||||
const borneLabel = activeBorneTypes.length === 1 ? BORNE_BY_LABEL[activeBorneTypes[0]] : '混合';
|
||||
return {
|
||||
customerId,
|
||||
customerName: list[0].customerName,
|
||||
borneLabel,
|
||||
quantityKg: sumKg(list),
|
||||
companyCost: sumAmount(company),
|
||||
unverifiedAmount: sumAmount(list.filter((x) => x.verifyStatus === 'unverified')),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.companyCost - a.companyCost || b.quantityKg - a.quantityKg);
|
||||
}
|
||||
|
||||
export const SOURCE_LABEL: Record<H2OrderRow['source'], string> = {
|
||||
api: 'API',
|
||||
manual: '补录',
|
||||
fence: '围栏',
|
||||
};
|
||||
|
||||
/** 总览 KPI:在宿主示意量级上按当前筛选(年/车辆/核对)等比缩放,保证卡片跟随顶栏筛选 */
|
||||
export function computeHostKpi(
|
||||
filtered: H2OrderRow[],
|
||||
year: number,
|
||||
allOrders: H2OrderRow[],
|
||||
base: {
|
||||
totalKgT: number;
|
||||
companyKgT: number;
|
||||
customerKgT: number;
|
||||
pendingKgT: number;
|
||||
totalFeeWan: number;
|
||||
companyFeeWan: number;
|
||||
customerFeeWan: number;
|
||||
pendingFeeWan: number;
|
||||
profitWan: number;
|
||||
incomeWan: number;
|
||||
costWan: number;
|
||||
monthKgT: number;
|
||||
monthFeeWan: number;
|
||||
monthYearPct: number;
|
||||
dayKg: number;
|
||||
dayFee: number;
|
||||
dayMonthPct: number;
|
||||
},
|
||||
) {
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
const baseline = filterOrders(allOrders, year, 'all', 'all');
|
||||
const baseKg = sumKg(baseline) || 1;
|
||||
const fKg = sumKg(filtered);
|
||||
const ratio = fKg / baseKg;
|
||||
|
||||
const companyKg = sumKg(filtered.filter((r) => r.borneBy === 'company'));
|
||||
const customerKg = sumKg(filtered.filter((r) => r.borneBy === 'customer'));
|
||||
const pendingKg = sumKg(filtered.filter((r) => r.borneBy === 'pending'));
|
||||
const split = companyKg + customerKg + pendingKg || 1;
|
||||
const companyShare = companyKg / split;
|
||||
const customerShare = customerKg / split;
|
||||
const pendingShare = pendingKg / split;
|
||||
|
||||
const monthRows = filtered.filter((r) => r.occurredAt.startsWith(`${year}-08`));
|
||||
const dayRows = filtered.filter((r) => r.occurredAt.startsWith(`${year}-08-08`));
|
||||
const monthKg = sumKg(monthRows);
|
||||
const dayKgVal = sumKg(dayRows);
|
||||
const monthAmt = sumAmount(monthRows);
|
||||
const dayAmt = sumAmount(dayRows);
|
||||
const yearKg = fKg || 1;
|
||||
const monthKgShare = monthKg / yearKg;
|
||||
const dayMonthShare = monthKg > 0 ? dayKgVal / monthKg : 0;
|
||||
|
||||
const totalKgT = round2(base.totalKgT * ratio);
|
||||
const totalFeeWan = round2(base.totalFeeWan * ratio);
|
||||
const incomeWan = round2(base.incomeWan * ratio);
|
||||
const costWan = round2(base.costWan * ratio);
|
||||
const profitWan = round2(base.profitWan * ratio);
|
||||
const monthKgT = round2(totalKgT * monthKgShare);
|
||||
const monthFeeWan = round2(totalFeeWan * monthKgShare);
|
||||
|
||||
return {
|
||||
totalKgT,
|
||||
companyKgT: round2(totalKgT * companyShare),
|
||||
customerKgT: round2(totalKgT * customerShare),
|
||||
pendingKgT: round2(totalKgT * pendingShare),
|
||||
totalFeeWan,
|
||||
companyFeeWan: round2(totalFeeWan * companyShare),
|
||||
customerFeeWan: round2(totalFeeWan * customerShare),
|
||||
pendingFeeWan: round2(totalFeeWan * pendingShare),
|
||||
profitWan,
|
||||
incomeWan,
|
||||
costWan,
|
||||
monthKgT,
|
||||
monthFeeWan,
|
||||
monthYearPct: round2(monthKgShare * 100),
|
||||
dayKg: round2(dayKgVal > 0 ? dayKgVal : base.dayKg * ratio * Math.max(dayMonthShare, 0.01)),
|
||||
dayFee: Math.round(dayAmt > 0 ? dayAmt : base.dayFee * ratio * Math.max(dayMonthShare, 0.01)),
|
||||
dayMonthPct: round2((dayMonthShare || base.dayMonthPct / 100) * 100),
|
||||
profitRatePct: incomeWan > 0 ? round2((profitWan / incomeWan) * 100) : 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import type { H2OrderRow, StationPrepaid } from '../types';
|
||||
|
||||
/** 辅助生成 200 条逼真高质量订单明细假数据 */
|
||||
function generate200MockOrders(): H2OrderRow[] {
|
||||
const stations = [
|
||||
{ id: 'st-ln', name: '佛山南海羚牛加氢站' },
|
||||
{ id: 'st-dp', name: '东鹏大道甲醇制氢一体站' },
|
||||
{ id: 'st-jx', name: '嘉兴中石化滨海加氢站' },
|
||||
{ id: 'st-jj', name: '嘉兴嘉锦加氢站' },
|
||||
{ id: 'st-cd', name: '成都中石化天府机场高速北站加氢站' },
|
||||
{ id: 'st-gz', name: '广州黄埔高新区氢能示范加氢站' },
|
||||
{ id: 'st-sh', name: '上海安亭加氢站' },
|
||||
];
|
||||
|
||||
const ownPlates = [
|
||||
'粤A99887', '粤B12001', '浙A52088', '浙F77881', '浙A88888F',
|
||||
'浙A66666', '浙F11223', '浙F33445', '川A77889', '川B99001',
|
||||
'沪A33219', '粤B88102', '浙F99812', '浙F66521', '粤A11029',
|
||||
];
|
||||
|
||||
const extPlates = [
|
||||
'粤A77661', '浙F33211', '川A55432', '沪B98765', '粤B66554',
|
||||
'浙A22334', '粤A99102', '川B88761', '无车牌(散车)',
|
||||
];
|
||||
|
||||
const customers = [
|
||||
{ id: 'c-ln', name: '羚牛自营', type: 'internal', deptId: 'd-ops', deptName: '运维中心' },
|
||||
{ id: 'c-bao', name: '包氢专线项目', type: 'internal', deptId: 'd-lease', deptName: '租赁业务一部' },
|
||||
{ id: 'c-lease-a', name: '嘉兴智奇供应链', type: 'internal', deptId: 'd-lease', deptName: '租赁业务一部' },
|
||||
{ id: 'c-log', name: '嘉兴益顺冷链', type: 'internal', deptId: 'd-log', deptName: '物流中心' },
|
||||
{ id: 'c-log2', name: '四川群彬物流', type: 'internal', deptId: 'd-log', deptName: '物流中心' },
|
||||
{ id: 'c-lease-b', name: '无锡铭康物流', type: 'internal', deptId: 'd-lease', deptName: '租赁业务二部' },
|
||||
{ id: 'c-ops', name: '运维调拨车辆', type: 'internal', deptId: 'd-ops', deptName: '运维中心' },
|
||||
{ id: 'c-pend', name: '待归属样本', type: 'internal', deptId: 'd-lease', deptName: '租赁业务一部' },
|
||||
{ id: 'c-ext-a', name: '广东氢动力', type: 'external', deptId: 'd-sales', deptName: '能源销售' },
|
||||
{ id: 'c-ext-b', name: '广东清运专线', type: 'external', deptId: 'd-sales', deptName: '能源销售' },
|
||||
{ id: 'c-ext-c', name: '东展供应链', type: 'external', deptId: 'd-sales', deptName: '能源销售' },
|
||||
{ id: 'c-ext-d', name: '顺丰冷运专线', type: 'external', deptId: 'd-sales', deptName: '能源销售' },
|
||||
{ id: 'c-ext-e', name: '极兔速递冷链', type: 'external', deptId: 'd-sales', deptName: '能源销售' },
|
||||
];
|
||||
|
||||
const list: H2OrderRow[] = [];
|
||||
|
||||
// 生成 200 条记录
|
||||
for (let i = 1; i <= 200; i++) {
|
||||
const padId = String(i).padStart(3, '0');
|
||||
|
||||
// 年份分配: 1~145 (2026年), 146~185 (2025年), 186~200 (2024年)
|
||||
let year = 2026;
|
||||
let month = Math.floor((i % 8)) + 1; // 1~8月
|
||||
if (i > 145 && i <= 185) {
|
||||
year = 2025;
|
||||
month = Math.floor((i % 12)) + 1;
|
||||
} else if (i > 185) {
|
||||
year = 2024;
|
||||
month = Math.floor((i % 12)) + 1;
|
||||
}
|
||||
|
||||
const day = (i * 7 % 28) + 1;
|
||||
const hour = (i * 3 % 14) + 7;
|
||||
const minute = (i * 11 % 50) + 5;
|
||||
|
||||
const mm = month < 10 ? `0${month}` : `${month}`;
|
||||
const dd = day < 10 ? `0${day}` : `${day}`;
|
||||
const hh = hour < 10 ? `0${hour}` : `${hour}`;
|
||||
const min = minute < 10 ? `0${minute}` : `${minute}`;
|
||||
|
||||
const occurredAt = `${year}-${mm}-${dd} ${hh}:${min}`;
|
||||
const station = stations[i % stations.length];
|
||||
const customer = customers[i % customers.length];
|
||||
|
||||
const isOwn = customer.type === 'internal';
|
||||
const fleet = isOwn ? 'own' : 'external';
|
||||
const plateNo = isOwn
|
||||
? ownPlates[i % ownPlates.length]
|
||||
: extPlates[i % extPlates.length];
|
||||
|
||||
// 单价 & 加氢量
|
||||
const unitPrice = [28, 30, 32, 35][i % 4];
|
||||
const quantityKg = Math.round((18 + (i * 3.7 % 85)) * 100) / 100;
|
||||
const amount = Math.round(quantityKg * unitPrice);
|
||||
|
||||
// 成本维度与费用承担(三类:自行结算并入客户承担)
|
||||
let borneBy: 'company' | 'customer' | 'pending' = 'company';
|
||||
let costDim: 'lease' | 'logistics' | 'ops' | 'pending' = 'lease';
|
||||
let leaseKind: 'company_borne' | 'package_h2' | undefined = undefined;
|
||||
let opsKind: 'abnormal' | 'transfer' | undefined = undefined;
|
||||
|
||||
const borneSlot = i % 10;
|
||||
if (customer.id === 'c-ext-a' || customer.id === 'c-ext-d' || borneSlot === 7 || borneSlot === 8) {
|
||||
borneBy = 'customer';
|
||||
} else if (borneSlot === 9) {
|
||||
borneBy = 'pending';
|
||||
}
|
||||
|
||||
const dimType = i % 5;
|
||||
if (borneBy !== 'company') {
|
||||
costDim = 'pending';
|
||||
leaseKind = undefined;
|
||||
opsKind = undefined;
|
||||
} else if (dimType === 0) {
|
||||
costDim = 'lease';
|
||||
leaseKind = 'company_borne';
|
||||
} else if (dimType === 1) {
|
||||
costDim = 'lease';
|
||||
leaseKind = 'package_h2';
|
||||
} else if (dimType === 2) {
|
||||
costDim = 'logistics';
|
||||
} else if (dimType === 3) {
|
||||
costDim = 'ops';
|
||||
opsKind = i % 2 === 0 ? 'abnormal' : 'transfer';
|
||||
} else {
|
||||
costDim = 'pending';
|
||||
}
|
||||
|
||||
// 核对状态与数据来源
|
||||
const verifyStatus = isOwn ? (i % 4 === 0 ? 'unverified' : 'verified') : 'unverified';
|
||||
const source = isOwn
|
||||
? (i % 3 === 0 ? 'manual' : i % 3 === 1 ? 'fence' : 'api')
|
||||
: (i % 2 === 0 ? 'api' : 'manual');
|
||||
|
||||
list.push({
|
||||
id: `HO-${String(year).slice(2)}${mm}-${padId}`,
|
||||
occurredAt,
|
||||
stationId: station.id,
|
||||
stationName: station.name,
|
||||
plateNo,
|
||||
customerId: customer.id,
|
||||
customerName: customer.name,
|
||||
deptId: customer.deptId,
|
||||
deptName: customer.deptName,
|
||||
amount,
|
||||
quantityKg,
|
||||
unitPrice,
|
||||
borneBy,
|
||||
costDim,
|
||||
leaseKind,
|
||||
opsKind,
|
||||
verifyStatus,
|
||||
source,
|
||||
fleet,
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 假数:200 条订单明细(三维度成本拆分) */
|
||||
export const MOCK_ORDERS: H2OrderRow[] = generate200MockOrders();
|
||||
|
||||
export const MOCK_PREPAID: StationPrepaid[] = [
|
||||
{
|
||||
stationId: 'st-jx',
|
||||
stationName: '嘉兴中石化滨海加氢站',
|
||||
openingBalance: 120000,
|
||||
openingAnchorLabel: '2025 年末财务期末',
|
||||
recharge: 80000,
|
||||
consume: 95000,
|
||||
},
|
||||
{
|
||||
stationId: 'st-jj',
|
||||
stationName: '嘉兴嘉锦加氢站',
|
||||
openingBalance: null,
|
||||
openingAnchorLabel: null,
|
||||
recharge: 40000,
|
||||
consume: 28000,
|
||||
},
|
||||
];
|
||||
|
||||
/** 宿主总览 KPI(三类承担口径,自行结算已并入客户承担) */
|
||||
export const HOST_KPI = {
|
||||
totalKgT: 697.16,
|
||||
companyKgT: 598.01,
|
||||
customerKgT: 80,
|
||||
pendingKgT: 19.15,
|
||||
totalFeeWan: 2093.71,
|
||||
companyFeeWan: 1795.95,
|
||||
customerFeeWan: 240,
|
||||
pendingFeeWan: 57.76,
|
||||
profitWan: 13.01,
|
||||
incomeWan: 2106.72,
|
||||
costWan: 2093.71,
|
||||
monthKgT: 16.67,
|
||||
monthFeeWan: 50.36,
|
||||
monthYearPct: 2.4,
|
||||
dayKg: 181.78,
|
||||
dayFee: 6533,
|
||||
dayMonthPct: 1.1,
|
||||
};
|
||||
|
||||
export const DEFAULT_YEAR = 2026;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @name 能源氢费经营看板
|
||||
* @description 嵌入 bi-next #hydrogen/overview · 我司成本三维度(非 OneOS V2) · 口令 lingniu
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import {
|
||||
type AnnotationSourceDocument,
|
||||
type AnnotationViewerOptions,
|
||||
} from '@axhub/annotation';
|
||||
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
|
||||
import { clearHostPrototypeRouteInfo } from '../../common/useHashPage';
|
||||
import { EnergyBiAccessGate, isEnergyBiAuthed } from './EnergyBiAccessGate';
|
||||
import { EnergyBiBoardApp } from './EnergyBiBoardApp';
|
||||
import annotationSourceDocument from './annotation-source.json';
|
||||
|
||||
function AuthedEnergyBiBoard() {
|
||||
const [ok, setOk] = useState(() => isEnergyBiAuthed());
|
||||
if (!ok) return <EnergyBiAccessGate onOk={() => setOk(true)} />;
|
||||
return <EnergyBiBoardApp />;
|
||||
}
|
||||
|
||||
export default function EnergyH2BiBoardEntry() {
|
||||
useEffect(() => {
|
||||
clearHostPrototypeRouteInfo();
|
||||
}, []);
|
||||
|
||||
const annotationOptions = useMemo<AnnotationViewerOptions>(
|
||||
() => ({ title: '能源氢费经营看板' }),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<PrototypeAnnotationHost
|
||||
source={annotationSourceDocument as unknown as AnnotationSourceDocument}
|
||||
options={annotationOptions}
|
||||
>
|
||||
<AuthedEnergyBiBoard />
|
||||
</PrototypeAnnotationHost>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined' && !window.location.pathname.startsWith('/prototypes/')) {
|
||||
const container = document.getElementById('root');
|
||||
if (container && !container.dataset.energyH2BiBoardMounted) {
|
||||
container.dataset.energyH2BiBoardMounted = '1';
|
||||
const root = createRoot(container);
|
||||
root.render(<EnergyH2BiBoardEntry />);
|
||||
}
|
||||
}
|
||||
+8045
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
||||
/** 能源 BI · 氢能总览嵌入功能 · 类型(宿主 bi-next #hydrogen/overview,非 OneOS V2) */
|
||||
|
||||
/** 我司成本三维度(本尊 2026-08-07) */
|
||||
export type CostDim = 'lease' | 'logistics' | 'ops' | 'pending';
|
||||
|
||||
/** 租赁成本二级 */
|
||||
export type LeaseKind = 'company_borne' | 'package_h2';
|
||||
|
||||
/** 运维成本二级 */
|
||||
export type OpsKind = 'abnormal' | 'transfer';
|
||||
|
||||
export type VerifyStatus = 'verified' | 'unverified';
|
||||
/** 氢费承担口径:自行结算并入客户承担,未明确归属的订单进入待核准。 */
|
||||
export type BorneBy = 'company' | 'customer' | 'pending';
|
||||
export type FleetScope = 'own' | 'external' | 'all';
|
||||
/** 按日 | 总览(站日报 / 现结登记已拆独立模块) */
|
||||
export type HostView = 'daily' | 'overview';
|
||||
|
||||
export interface H2OrderRow {
|
||||
id: string;
|
||||
occurredAt: string;
|
||||
stationId: string;
|
||||
stationName: string;
|
||||
plateNo: string;
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
deptId: string;
|
||||
deptName: string;
|
||||
amount: number;
|
||||
quantityKg: number;
|
||||
unitPrice: number;
|
||||
borneBy: BorneBy;
|
||||
costDim: CostDim;
|
||||
leaseKind?: LeaseKind;
|
||||
opsKind?: OpsKind;
|
||||
verifyStatus: VerifyStatus;
|
||||
source: 'api' | 'manual' | 'fence';
|
||||
fleet: 'own' | 'external';
|
||||
}
|
||||
|
||||
export interface StationPrepaid {
|
||||
stationId: string;
|
||||
stationName: string;
|
||||
openingBalance: number | null;
|
||||
openingAnchorLabel: string | null;
|
||||
recharge: number;
|
||||
consume: number;
|
||||
}
|
||||
|
||||
export const COST_DIM_LABEL: Record<CostDim, string> = {
|
||||
lease: '租赁成本',
|
||||
logistics: '物流成本',
|
||||
ops: '运维成本',
|
||||
pending: '待归属',
|
||||
};
|
||||
|
||||
export const LEASE_KIND_LABEL: Record<LeaseKind, string> = {
|
||||
company_borne: '我司承担',
|
||||
package_h2: '包氢项目',
|
||||
};
|
||||
|
||||
export const OPS_KIND_LABEL: Record<OpsKind, string> = {
|
||||
abnormal: '异动',
|
||||
transfer: '调拨',
|
||||
};
|
||||
|
||||
export const BORNE_BY_LABEL: Record<BorneBy, string> = {
|
||||
company: '我司承担',
|
||||
customer: '客户承担',
|
||||
pending: '待核准',
|
||||
};
|
||||
|
||||
export const BORNE_BY_ORDER: BorneBy[] = ['company', 'customer', 'pending'];
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
# 加氢站日报 · 产品需求说明(PRD)
|
||||
|
||||
> 原型路径:`src/prototypes/energy-h2-station-daily`
|
||||
> 口令:`lingniu`(轻门禁)
|
||||
> 设计:能源 BI / 经营看板皮(非 OneOS V2 台账)
|
||||
> 对外:只叙事、禁听众标签、禁说明书墙
|
||||
> 作者:OneOS
|
||||
> 日期:2026-08-12
|
||||
> **2026-08-13**:去掉总览堆积「加氢量占比」条;占比改到各站概况行内「加氢量占比」(含进度条);各站按区间加氢量从高到低排序
|
||||
|
||||
---
|
||||
|
||||
## 0. 定位与边界
|
||||
|
||||
| 项 | 口径 |
|
||||
|---|---|
|
||||
| 名称 | 加氢站日报 |
|
||||
| 职责 | 查询区间总览 → 钻取单站明细;现结区展示进账事实 |
|
||||
| 不做 | 现结登记办理;经营看板三维度;台账列表总览;「单站驾驶舱」表述;总览页导出 |
|
||||
| 与经营看板 | 可嵌入经营看板「单站」;也可独立打开。**嵌入时看板标题旁不展示查询区间**(维度不同) |
|
||||
|
||||
### 视图决策
|
||||
|
||||
| 决策 | 结论 | 理由 |
|
||||
|---|---|---|
|
||||
| 总览形态 | Hero KPI + 各站单卡片(行内加氢量占比) | 经营读数与下钻;占比不下单独堆积条 |
|
||||
| 各站概况 | **单卡片**;默认「全部」,右上可切「单站」;**按区间加氢量降序** | 本尊 2026-08-12 / 2026-08-13 |
|
||||
| 台账列表总览 | **不做** | 缺经营感 |
|
||||
| 明细表集 | 日汇总 → 区间趋势 → 客户月量/费 →(收支∥现结明细) | 页长可控 |
|
||||
| 车辆明细 | **不外挂**;仅导出取证派生 | 外层过长 |
|
||||
| 时间维度 | **起止日期**(精确到日)+ 本日/本周/本月快捷;下方 KPI/站卡按区间重算 | 本尊 2026-08-12 |
|
||||
| 看板嵌入 | 单站模式**不显示**经营看板顶栏时间芯片 | 查询维度不同 |
|
||||
| 空数值 | 写 `0` | 本尊 2026-08-12 |
|
||||
| 听众标签 | **禁止**进页面 | 只说事不对人 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 用户故事
|
||||
|
||||
- **起点:** 打开日报 / 看板「单站」→ 选查询起止日期。
|
||||
- **怎么运作:** KPI「统计加氢总量 / 统计加氢量 / 统计现结金额」可点开日明细;各站行内看「加氢量占比」与进度条;点站卡进站明细。
|
||||
- **闭环:** 钻取页可导出 `.xlsx`;总览无导出。
|
||||
|
||||
---
|
||||
|
||||
## 2. 验收
|
||||
|
||||
1. 口令 `lingniu`。
|
||||
2. 看板单站模式:标题旁**无**查询区间芯片;页面无「单站驾驶舱」文案。
|
||||
3. 查询日期为起止选择器,含本日/本周/本月;改区间后 KPI/站卡同步变化。
|
||||
4. 加氢站副文案为「统计站点数」;三个 KPI 可钻取日表(条数=区间天数)。
|
||||
5. **无**总览堆积占比条;各站行有「加氢量占比」数字 + 进度条。
|
||||
6. 各站概况为单卡片,右上「全部 / 单站」切换;全部列表按区间加氢量从高到低。
|
||||
|
||||
---
|
||||
|
||||
## 3. 关联
|
||||
|
||||
- 现结登记:`energy-spot-cash-intake`
|
||||
- 经营看板:`energy-h2-bi-board`
|
||||
- 共享现结:`oneos-energy-spot-cash-intake-v3`
|
||||
- 加氢客户(外部)/ 外部车辆:`energy-h2-external-customer` · `energy-h2-external-vehicle`(外部量仅统计,不进车辆氢费明细)
|
||||
- 量侧实站(禁造站):南海 + 东鹏大道(未提供 Excel 不上 mock)
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 客户多选(能源 BI 皮 · 禁 V2)
|
||||
*/
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Check, ChevronDown, X } from 'lucide-react';
|
||||
|
||||
export const SdCustomerMultiSelect: React.FC<{
|
||||
options: string[];
|
||||
value: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
label?: string;
|
||||
}> = ({ options, value, onChange, label = '客户' }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [q, setQ] = useState('');
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
return () => document.removeEventListener('mousedown', onDoc);
|
||||
}, [open]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const key = q.trim();
|
||||
if (!key) return options;
|
||||
return options.filter((n) => n.includes(key));
|
||||
}, [options, q]);
|
||||
|
||||
const allSelected = value.length === 0 || value.length === options.length;
|
||||
const triggerText = allSelected
|
||||
? '全部客户'
|
||||
: value.length <= 2
|
||||
? value.join('、')
|
||||
: `已选 ${value.length} 家`;
|
||||
|
||||
const toggle = (name: string) => {
|
||||
if (value.length === 0) {
|
||||
// 从「全部」切入:只留当前点中
|
||||
onChange([name]);
|
||||
return;
|
||||
}
|
||||
if (value.includes(name)) {
|
||||
const next = value.filter((n) => n !== name);
|
||||
onChange(next.length === 0 ? [] : next);
|
||||
return;
|
||||
}
|
||||
const next = [...value, name];
|
||||
onChange(next.length === options.length ? [] : next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`sd-msel ${open ? 'is-open' : ''}`} ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="sd-msel__trigger"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<span className="sd-msel__label">{label}</span>
|
||||
<span className="sd-msel__value" title={triggerText}>
|
||||
{triggerText}
|
||||
</span>
|
||||
<ChevronDown size={14} aria-hidden className="sd-msel__chev" />
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="sd-msel__panel" role="listbox" aria-multiselectable>
|
||||
<div className="sd-msel__search">
|
||||
<input
|
||||
type="search"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="搜索客户"
|
||||
aria-label="搜索客户"
|
||||
/>
|
||||
{q ? (
|
||||
<button type="button" className="sd-msel__clear" onClick={() => setQ('')} aria-label="清空搜索">
|
||||
<X size={12} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="sd-msel__actions">
|
||||
<button type="button" onClick={() => onChange([])}>
|
||||
全选
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange([]);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
<ul className="sd-msel__list">
|
||||
{filtered.length === 0 ? (
|
||||
<li className="sd-msel__empty">无匹配客户</li>
|
||||
) : (
|
||||
filtered.map((name) => {
|
||||
const checked = allSelected || value.includes(name);
|
||||
return (
|
||||
<li key={name}>
|
||||
<button
|
||||
type="button"
|
||||
className={`sd-msel__opt ${checked ? 'is-on' : ''}`}
|
||||
role="option"
|
||||
aria-selected={checked}
|
||||
onClick={() => toggle(name)}
|
||||
>
|
||||
<span className="sd-msel__check" aria-hidden>
|
||||
{checked ? <Check size={12} /> : null}
|
||||
</span>
|
||||
<span className="sd-msel__name" title={name}>{name}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 站日报 · 自定义日期(非原生 type=date),对齐能源 BI 皮
|
||||
*/
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Calendar, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
function pad2(n: number) {
|
||||
return n < 10 ? `0${n}` : `${n}`;
|
||||
}
|
||||
|
||||
function parseYmd(value: string) {
|
||||
const parts = value.split('-');
|
||||
const year = parseInt(parts[0], 10) || 2026;
|
||||
const month = parseInt(parts[1], 10) || 1;
|
||||
const day = parseInt(parts[2], 10) || 1;
|
||||
return { year, month, day };
|
||||
}
|
||||
|
||||
function toYmd(year: number, month: number, day: number) {
|
||||
return `${year}-${pad2(month)}-${pad2(day)}`;
|
||||
}
|
||||
|
||||
function displayYmd(value: string) {
|
||||
const { year, month, day } = parseYmd(value);
|
||||
return `${year}-${pad2(month)}-${pad2(day)}`;
|
||||
}
|
||||
|
||||
export const SdDatePicker: React.FC<{
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (ymd: string) => void;
|
||||
align?: 'left' | 'right';
|
||||
}> = ({ label, value, onChange, align = 'right' }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const parsed = useMemo(() => parseYmd(value), [value]);
|
||||
const [viewYear, setViewYear] = useState(parsed.year);
|
||||
const [viewMonth, setViewMonth] = useState(parsed.month);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setViewYear(parsed.year);
|
||||
setViewMonth(parsed.month);
|
||||
}, [open, parsed.year, parsed.month]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
return () => document.removeEventListener('mousedown', onDoc);
|
||||
}, [open]);
|
||||
|
||||
const daysInMonth = new Date(viewYear, viewMonth, 0).getDate();
|
||||
const firstWeekday = new Date(viewYear, viewMonth - 1, 1).getDay();
|
||||
const days = Array.from({ length: daysInMonth }, (_, i) => i + 1);
|
||||
const blanks = Array.from({ length: firstWeekday }, (_, i) => i);
|
||||
|
||||
const goPrev = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (viewMonth === 1) {
|
||||
setViewYear((y) => y - 1);
|
||||
setViewMonth(12);
|
||||
} else {
|
||||
setViewMonth((m) => m - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const goNext = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (viewMonth === 12) {
|
||||
setViewYear((y) => y + 1);
|
||||
setViewMonth(1);
|
||||
} else {
|
||||
setViewMonth((m) => m + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const pickDay = (day: number, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onChange(toYmd(viewYear, viewMonth, day));
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const pickToday = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const now = new Date();
|
||||
onChange(toYmd(now.getFullYear(), now.getMonth() + 1, now.getDate()));
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`sd-date ${align === 'right' ? 'sd-date--right' : ''}`} ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`sd-date__trigger ${open ? 'is-open' : ''}`}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="sd-date__label">{label}</span>
|
||||
<span className="sd-date__value">{displayYmd(value)}</span>
|
||||
<Calendar size={15} aria-hidden className="sd-date__icon" />
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div className="sd-date__popover" role="dialog" aria-label={label}>
|
||||
<div className="sd-date__header">
|
||||
<button type="button" className="sd-date__nav" onClick={goPrev} aria-label="上一月">
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<div className="sd-date__title">
|
||||
{viewYear}年{pad2(viewMonth)}月
|
||||
</div>
|
||||
<button type="button" className="sd-date__nav" onClick={goNext} aria-label="下一月">
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="sd-date__week">
|
||||
{['日', '一', '二', '三', '四', '五', '六'].map((w) => (
|
||||
<span key={w}>{w}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="sd-date__grid">
|
||||
{blanks.map((i) => (
|
||||
<span key={`b-${i}`} className="sd-date__day is-empty" />
|
||||
))}
|
||||
{days.map((d) => {
|
||||
const selected =
|
||||
parsed.year === viewYear && parsed.month === viewMonth && parsed.day === d;
|
||||
return (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
className={`sd-date__day ${selected ? 'is-selected' : ''}`}
|
||||
onClick={(e) => pickDay(d, e)}
|
||||
>
|
||||
{d}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="sd-date__footer">
|
||||
<button type="button" className="sd-date__today" onClick={pickToday}>
|
||||
今天
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* 站日报 · 起止日期区间(精确到日)+ 本日/本周/本月快捷
|
||||
* 能源 BI 皮 · 禁原生 type=date · 禁 V2
|
||||
*/
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Calendar, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
function pad2(n: number) {
|
||||
return n < 10 ? `0${n}` : `${n}`;
|
||||
}
|
||||
|
||||
export function parseYmd(value: string) {
|
||||
const parts = value.split('-');
|
||||
const year = parseInt(parts[0], 10) || 2026;
|
||||
const month = parseInt(parts[1], 10) || 1;
|
||||
const day = parseInt(parts[2], 10) || 1;
|
||||
return { year, month, day };
|
||||
}
|
||||
|
||||
export function toYmd(year: number, month: number, day: number) {
|
||||
return `${year}-${pad2(month)}-${pad2(day)}`;
|
||||
}
|
||||
|
||||
export function displayYmd(value: string) {
|
||||
const { year, month, day } = parseYmd(value);
|
||||
return `${year}-${pad2(month)}-${pad2(day)}`;
|
||||
}
|
||||
|
||||
function ymdFromDate(d: Date) {
|
||||
return toYmd(d.getFullYear(), d.getMonth() + 1, d.getDate());
|
||||
}
|
||||
|
||||
function startOfWeek(d: Date) {
|
||||
const x = new Date(d);
|
||||
const day = x.getDay();
|
||||
const diff = day === 0 ? -6 : 1 - day; // 周一起
|
||||
x.setDate(x.getDate() + diff);
|
||||
return x;
|
||||
}
|
||||
|
||||
export function rangeShortcuts(anchor = new Date()): Record<string, { start: string; end: string }> {
|
||||
const today = ymdFromDate(anchor);
|
||||
const sow = startOfWeek(anchor);
|
||||
const eow = new Date(sow);
|
||||
eow.setDate(sow.getDate() + 6);
|
||||
const som = toYmd(anchor.getFullYear(), anchor.getMonth() + 1, 1);
|
||||
const eomDate = new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0);
|
||||
return {
|
||||
today: { start: today, end: today },
|
||||
week: { start: ymdFromDate(sow), end: ymdFromDate(eow) },
|
||||
month: { start: som, end: ymdFromDate(eomDate) },
|
||||
};
|
||||
}
|
||||
|
||||
type PickTarget = 'start' | 'end';
|
||||
|
||||
export const SdDateRangePicker: React.FC<{
|
||||
label?: string;
|
||||
start: string;
|
||||
end: string;
|
||||
onChange: (next: { start: string; end: string }) => void;
|
||||
align?: 'left' | 'right';
|
||||
/** 日历锚点日期(原型固定业务日,避免本机今天跑偏) */
|
||||
anchorYmd?: string;
|
||||
}> = ({ label = '查询日期', start, end, onChange, align = 'right', anchorYmd }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [picking, setPicking] = useState<PickTarget>('start');
|
||||
const [draftStart, setDraftStart] = useState(start);
|
||||
const [draftEnd, setDraftEnd] = useState(end);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const anchor = useMemo(() => {
|
||||
if (anchorYmd) {
|
||||
const p = parseYmd(anchorYmd);
|
||||
return new Date(p.year, p.month - 1, p.day);
|
||||
}
|
||||
return new Date();
|
||||
}, [anchorYmd]);
|
||||
|
||||
const startP = useMemo(() => parseYmd(draftStart), [draftStart]);
|
||||
const endP = useMemo(() => parseYmd(draftEnd), [draftEnd]);
|
||||
const focus = picking === 'start' ? startP : endP;
|
||||
const [viewYear, setViewYear] = useState(focus.year);
|
||||
const [viewMonth, setViewMonth] = useState(focus.month);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setDraftStart(start);
|
||||
setDraftEnd(end);
|
||||
setPicking('start');
|
||||
const p = parseYmd(start);
|
||||
setViewYear(p.year);
|
||||
setViewMonth(p.month);
|
||||
}, [open, start, end]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
return () => document.removeEventListener('mousedown', onDoc);
|
||||
}, [open]);
|
||||
|
||||
const daysInMonth = new Date(viewYear, viewMonth, 0).getDate();
|
||||
const firstWeekday = new Date(viewYear, viewMonth - 1, 1).getDay();
|
||||
const days = Array.from({ length: daysInMonth }, (_, i) => i + 1);
|
||||
const blanks = Array.from({ length: firstWeekday }, (_, i) => i);
|
||||
|
||||
const goPrev = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (viewMonth === 1) {
|
||||
setViewYear((y) => y - 1);
|
||||
setViewMonth(12);
|
||||
} else setViewMonth((m) => m - 1);
|
||||
};
|
||||
|
||||
const goNext = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (viewMonth === 12) {
|
||||
setViewYear((y) => y + 1);
|
||||
setViewMonth(1);
|
||||
} else setViewMonth((m) => m + 1);
|
||||
};
|
||||
|
||||
const inRange = (ymd: string) => ymd >= draftStart && ymd <= draftEnd;
|
||||
const isEdge = (ymd: string) => ymd === draftStart || ymd === draftEnd;
|
||||
|
||||
const pickDay = (day: number, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const ymd = toYmd(viewYear, viewMonth, day);
|
||||
if (picking === 'start') {
|
||||
const nextStart = ymd;
|
||||
const nextEnd = ymd > draftEnd ? ymd : draftEnd;
|
||||
setDraftStart(nextStart);
|
||||
setDraftEnd(nextEnd);
|
||||
setPicking('end');
|
||||
return;
|
||||
}
|
||||
if (ymd < draftStart) {
|
||||
setDraftStart(ymd);
|
||||
setDraftEnd(draftStart);
|
||||
} else {
|
||||
setDraftEnd(ymd);
|
||||
}
|
||||
setPicking('start');
|
||||
};
|
||||
|
||||
const applyDraft = () => {
|
||||
const a = draftStart <= draftEnd ? draftStart : draftEnd;
|
||||
const b = draftStart <= draftEnd ? draftEnd : draftStart;
|
||||
onChange({ start: a, end: b });
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const applyShortcut = (key: 'today' | 'week' | 'month') => {
|
||||
const map = rangeShortcuts(anchor);
|
||||
const r = map[key];
|
||||
setDraftStart(r.start);
|
||||
setDraftEnd(r.end);
|
||||
onChange(r);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`sd-date sd-date--range ${align === 'right' ? 'sd-date--right' : ''}`} ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`sd-date__trigger ${open ? 'is-open' : ''}`}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="sd-date__label">{label}</span>
|
||||
<span className="sd-date__value">
|
||||
{displayYmd(start)} 至 {displayYmd(end)}
|
||||
</span>
|
||||
<Calendar size={15} aria-hidden className="sd-date__icon" />
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div className="sd-date__popover sd-date__popover--range" role="dialog" aria-label={label}>
|
||||
<div className="sd-date__shortcuts">
|
||||
<button type="button" onClick={() => applyShortcut('today')}>
|
||||
本日
|
||||
</button>
|
||||
<button type="button" onClick={() => applyShortcut('week')}>
|
||||
本周
|
||||
</button>
|
||||
<button type="button" onClick={() => applyShortcut('month')}>
|
||||
本月
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="sd-date__range-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={picking === 'start' ? 'is-on' : ''}
|
||||
onClick={() => setPicking('start')}
|
||||
>
|
||||
开始 {displayYmd(draftStart)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={picking === 'end' ? 'is-on' : ''}
|
||||
onClick={() => setPicking('end')}
|
||||
>
|
||||
结束 {displayYmd(draftEnd)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="sd-date__header">
|
||||
<button type="button" className="sd-date__nav" onClick={goPrev} aria-label="上一月">
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<div className="sd-date__title">
|
||||
{viewYear}年{pad2(viewMonth)}月
|
||||
</div>
|
||||
<button type="button" className="sd-date__nav" onClick={goNext} aria-label="下一月">
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="sd-date__week">
|
||||
{['日', '一', '二', '三', '四', '五', '六'].map((w) => (
|
||||
<span key={w}>{w}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="sd-date__grid">
|
||||
{blanks.map((i) => (
|
||||
<span key={`b-${i}`} className="sd-date__day is-empty" />
|
||||
))}
|
||||
{days.map((d) => {
|
||||
const ymd = toYmd(viewYear, viewMonth, d);
|
||||
const cls = [
|
||||
'sd-date__day',
|
||||
isEdge(ymd) ? 'is-selected' : '',
|
||||
inRange(ymd) ? 'is-in-range' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
return (
|
||||
<button key={d} type="button" className={cls} onClick={(e) => pickDay(d, e)}>
|
||||
{d}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="sd-date__footer sd-date__footer--range">
|
||||
<button type="button" className="sd-date__today" onClick={() => applyShortcut('today')}>
|
||||
本日
|
||||
</button>
|
||||
<button type="button" className="sd-btn sd-btn--primary sd-date__apply" onClick={applyDraft}>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import React, { useState } from 'react';
|
||||
import '../energy-h2-bi-board/styles/energy-bi-board.css';
|
||||
import './styles.css';
|
||||
|
||||
export const STATION_DAILY_PASSWORD = 'lingniu';
|
||||
export const STATION_DAILY_AUTH_KEY = 'energy-h2-station-daily-auth-v1';
|
||||
|
||||
export function isStationDailyAuthed(): boolean {
|
||||
try {
|
||||
return sessionStorage.getItem(STATION_DAILY_AUTH_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setStationDailyAuthed(ok: boolean): void {
|
||||
try {
|
||||
if (ok) sessionStorage.setItem(STATION_DAILY_AUTH_KEY, '1');
|
||||
else sessionStorage.removeItem(STATION_DAILY_AUTH_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export const StationDailyAccessGate: React.FC<{ onOk: () => void }> = ({ onOk }) => {
|
||||
const [pwd, setPwd] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (pwd.trim() === STATION_DAILY_PASSWORD) {
|
||||
setStationDailyAuthed(true);
|
||||
setErr('');
|
||||
onOk();
|
||||
return;
|
||||
}
|
||||
setErr('口令不对,请重试');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ehb-gate">
|
||||
<form className="ehb-gate-card" onSubmit={submit}>
|
||||
<p className="ehb-gate-kicker">ONEOS · 能源 BI</p>
|
||||
<h1 className="ehb-gate-title">加氢站日报</h1>
|
||||
<p className="ehb-gate-sub">全站加氢量 · 现结进账</p>
|
||||
<label className="ehb-gate-label" htmlFor="sd-pwd">
|
||||
访问口令
|
||||
</label>
|
||||
<input
|
||||
id="sd-pwd"
|
||||
className="ehb-gate-input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
value={pwd}
|
||||
onChange={(e) => {
|
||||
setPwd(e.target.value);
|
||||
if (err) setErr('');
|
||||
}}
|
||||
placeholder="请输入口令"
|
||||
/>
|
||||
<p className="ehb-gate-error" role="alert">
|
||||
{err}
|
||||
</p>
|
||||
<button type="submit" className="ehb-gate-btn">
|
||||
进入站日报
|
||||
</button>
|
||||
<p className="ehb-gate-foot">内部文件 · 请勿外传</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+545
@@ -0,0 +1,545 @@
|
||||
// @ts-nocheck — verbatim 8113 prototype source; runtime DOM/CSS is intentionally preserved.
|
||||
/**
|
||||
* 加氢站日报 · 查询区间总览 → 钻取单站
|
||||
* 起止日期驱动计算;对外只叙事
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { ArrowUpRight, Fuel, MapPin, RefreshCw, X } from 'lucide-react';
|
||||
import { MobileListFullscreenButton } from '../../common/MobileListFullscreenButton';
|
||||
import { fetchHydrogenStationBoard } from '../../../../modules/energy/api';
|
||||
import type { HydrogenStationBoardResponse } from '../../../../modules/energy/types';
|
||||
import type { StationDailyVolumeRow } from './data/mockStationDaily';
|
||||
import { SdDateRangePicker } from './SdDateRangePicker';
|
||||
import { StationDailyDetailView } from './StationDailyDetailView';
|
||||
import './styles.css';
|
||||
|
||||
function money(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function kg(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
/** 闭区间日历天数 */
|
||||
function calendarDays(start: string, end: string): string[] {
|
||||
const days: string[] = [];
|
||||
const cursor = new Date(`${start}T00:00:00`);
|
||||
const last = new Date(`${end}T00:00:00`);
|
||||
while (cursor <= last) {
|
||||
const y = cursor.getFullYear();
|
||||
const m = String(cursor.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(cursor.getDate()).padStart(2, '0');
|
||||
days.push(`${y}-${m}-${d}`);
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
type StationOverview = {
|
||||
id: string;
|
||||
name: string;
|
||||
region: string;
|
||||
endKg: number;
|
||||
endAmt: number;
|
||||
endVehicles: number;
|
||||
rangeKg: number;
|
||||
rangeAmt: number;
|
||||
cashDays: number;
|
||||
cashTotal: number;
|
||||
vols: StationDailyVolumeRow[];
|
||||
share: number;
|
||||
};
|
||||
|
||||
type KpiDrill = 'volumeTotal' | 'volumeDays' | 'cashDays' | null;
|
||||
|
||||
function StationTrend({ vols }: { vols: StationDailyVolumeRow[] }) {
|
||||
const recent = vols.slice(-7);
|
||||
const peak = Math.max(...recent.map((v) => v.quantityKg), 1);
|
||||
return (
|
||||
<div className="sd-station-row__trend" aria-label="近7日加氢趋势">
|
||||
<span className="sd-station-row__trend-title">近7日趋势</span>
|
||||
<div className="sd-station-row__trend-bars">
|
||||
{recent.map((v) => (
|
||||
<div
|
||||
key={v.date}
|
||||
className={`sd-station-row__trend-col ${v.quantityKg === 0 ? 'is-zero' : ''}`}
|
||||
aria-label={`${v.date},加氢量 ${kg(v.quantityKg)} Kg`}
|
||||
>
|
||||
<span className="sd-station-row__trend-tooltip" role="tooltip">
|
||||
<strong>{v.date}</strong>
|
||||
<em>{kg(v.quantityKg)} Kg</em>
|
||||
</span>
|
||||
<span
|
||||
className="sd-station-row__trend-bar-slot"
|
||||
style={{ '--trend-bar-height': v.quantityKg === 0 ? '0%' : `${(v.quantityKg / peak) * 100}%` } as React.CSSProperties}
|
||||
>
|
||||
<span className="sd-station-row__trend-value">
|
||||
{v.quantityKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
<i style={{ height: v.quantityKg === 0 ? '0' : `${(v.quantityKg / peak) * 100}%` }} />
|
||||
</span>
|
||||
<small>{v.date.slice(5)}</small>
|
||||
</div>
|
||||
))}
|
||||
{recent.length === 0 ? <span className="sd-station-row__trend-empty">暂无趋势数据</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StationFilter({ value, rows, onChange }: { value: string; rows: StationOverview[]; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<select className="sd-station-filter-select" value={value} onChange={(e) => onChange(e.target.value)} aria-label="选择当前站点">
|
||||
{rows.map((row) => (
|
||||
<option key={row.id} value={row.id}>
|
||||
{row.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function localIsoDate(date: Date) {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const defaultRange = (() => {
|
||||
const end = new Date();
|
||||
const start = new Date(end);
|
||||
start.setDate(end.getDate() - 9);
|
||||
return { startDate: localIsoDate(start), end: localIsoDate(end) };
|
||||
})();
|
||||
|
||||
function isAllowedSingleStation(name: string) {
|
||||
return name.includes('东鹏大道') || (name.includes('佛山南海') && name.includes('羚牛'));
|
||||
}
|
||||
|
||||
type StationDailyAppProps = {
|
||||
embedded?: boolean;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
onStartDateChange?: (value: string) => void;
|
||||
onEndDateChange?: (value: string) => void;
|
||||
refreshToken?: number;
|
||||
onLoadingChange?: (loading: boolean) => void;
|
||||
};
|
||||
|
||||
export const StationDailyApp: React.FC<StationDailyAppProps> = ({
|
||||
embedded = false,
|
||||
startDate: controlledStartDate,
|
||||
endDate: controlledEndDate,
|
||||
onStartDateChange,
|
||||
onEndDateChange,
|
||||
refreshToken = 0,
|
||||
onLoadingChange,
|
||||
}) => {
|
||||
const [localStartDate, setLocalStartDate] = useState(defaultRange.startDate);
|
||||
const [localEndDate, setLocalEndDate] = useState(defaultRange.end);
|
||||
const startDate = controlledStartDate ?? localStartDate;
|
||||
const endDate = controlledEndDate ?? localEndDate;
|
||||
const setStartDate = onStartDateChange ?? setLocalStartDate;
|
||||
const setEndDate = onEndDateChange ?? setLocalEndDate;
|
||||
const [drillStationId, setDrillStationId] = useState<string | null>(null);
|
||||
const [updatedAt, setUpdatedAt] = useState('2026-08-12 10:00');
|
||||
const [tick, setTick] = useState(0);
|
||||
const [kpiDrill, setKpiDrill] = useState<KpiDrill>(null);
|
||||
const [stationFilter, setStationFilter] = useState('');
|
||||
const [liveBoard, setLiveBoard] = useState<HydrogenStationBoardResponse | null>(null);
|
||||
const [liveError, setLiveError] = useState<string | null>(null);
|
||||
const [liveLoading, setLiveLoading] = useState(true);
|
||||
const [stationCashBoard, setStationCashBoard] = useState<HydrogenStationBoardResponse | null>(null);
|
||||
const [stationCashError, setStationCashError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setLiveLoading(true);
|
||||
onLoadingChange?.(true);
|
||||
setLiveError(null);
|
||||
fetchHydrogenStationBoard({ startDate, endDate, force: tick > 0 })
|
||||
.then((result) => {
|
||||
if (!active) return;
|
||||
setLiveBoard(result);
|
||||
setUpdatedAt(result.summary.latestLedgerTime ?? '暂无账本更新时间');
|
||||
setLiveLoading(false);
|
||||
onLoadingChange?.(false);
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!active) return;
|
||||
setLiveError(reason instanceof Error ? reason.message : '单站统计加载失败');
|
||||
setLiveLoading(false);
|
||||
onLoadingChange?.(false);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [startDate, endDate, tick, refreshToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!drillStationId) return;
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'auto' });
|
||||
document.querySelector<HTMLElement>('.ehb-body')?.scrollTo({ top: 0, behavior: 'auto' });
|
||||
});
|
||||
}, [drillStationId]);
|
||||
|
||||
const overviewRows: StationOverview[] = useMemo(() => {
|
||||
const base = (liveBoard?.stations ?? [])
|
||||
.map((st) => {
|
||||
const vols = st.dailyKg.map((row) => ({
|
||||
date: row.date,
|
||||
stationId: String(st.id),
|
||||
quantityKg: row.kg,
|
||||
unitPrice: st.kg > 0 ? st.fee / st.kg : 0,
|
||||
amountYuan: st.kg > 0 ? row.kg * st.fee / st.kg : 0,
|
||||
vehicleCount: 0,
|
||||
}));
|
||||
const endVol = vols.find((r) => r.date === endDate) || vols[vols.length - 1];
|
||||
return {
|
||||
id: String(st.id),
|
||||
name: st.name,
|
||||
region: [st.province, st.city].filter(Boolean).join(' · '),
|
||||
endKg: endVol?.quantityKg ?? 0,
|
||||
endAmt: endVol?.amountYuan ?? 0,
|
||||
endVehicles: st.recordCount,
|
||||
rangeKg: st.kg,
|
||||
rangeAmt: st.fee,
|
||||
cashDays: st.paymentCount,
|
||||
cashTotal: st.paymentAmount,
|
||||
vols,
|
||||
share: st.share,
|
||||
};
|
||||
});
|
||||
return base
|
||||
.filter((station) => isAllowedSingleStation(station.name))
|
||||
.sort((a, b) => b.rangeKg - a.rangeKg || a.name.localeCompare(b.name, 'zh-CN'));
|
||||
}, [liveBoard, endDate]);
|
||||
|
||||
// 单站入口只保留一条真实站点记录。首次拿到真实列表时按服务端排序后的首站默认选中。
|
||||
const selectedStationId = overviewRows.some((row) => row.id === stationFilter)
|
||||
? stationFilter
|
||||
: (overviewRows[0]?.id ?? '');
|
||||
const boardRows = selectedStationId ? overviewRows.filter((row) => row.id === selectedStationId) : [];
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedStationId && selectedStationId !== stationFilter) setStationFilter(selectedStationId);
|
||||
}, [selectedStationId, stationFilter]);
|
||||
|
||||
// 总览请求的 summary.daily 是全站口径;单站现结日表必须另以真实 stationId 查询,不能按比例拆分。
|
||||
useEffect(() => {
|
||||
if (!selectedStationId) {
|
||||
setStationCashBoard(null);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
setStationCashBoard(null);
|
||||
setStationCashError(null);
|
||||
fetchHydrogenStationBoard({ startDate, endDate, stationId: Number(selectedStationId), force: tick > 0 })
|
||||
.then((result) => {
|
||||
if (active) setStationCashBoard(result);
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (active) setStationCashError(reason instanceof Error ? reason.message : '当前站点现结流水加载失败');
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [selectedStationId, startDate, endDate, tick]);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
return {
|
||||
rangeKg: boardRows.reduce((sum, row) => sum + row.rangeKg, 0),
|
||||
rangeAmt: boardRows.reduce((sum, row) => sum + row.rangeAmt, 0),
|
||||
cashTotal: boardRows.reduce((sum, row) => sum + row.cashTotal, 0),
|
||||
endKg: boardRows.reduce((sum, row) => sum + row.endKg, 0),
|
||||
endVehicles: boardRows.reduce((sum, row) => sum + row.endVehicles, 0),
|
||||
};
|
||||
}, [boardRows]);
|
||||
|
||||
/** 区间内按日汇总(全站 · 无量日补 0) */
|
||||
const dailyAgg = useMemo(() => {
|
||||
const map = new Map<string, { date: string; kg: number; amt: number; vehicles: number }>();
|
||||
boardRows.forEach((station) => station.vols.forEach((row) => {
|
||||
const current = map.get(row.date) ?? { date: row.date, kg: 0, amt: 0, vehicles: 0 };
|
||||
current.kg += row.quantityKg;
|
||||
current.amt += row.amountYuan;
|
||||
map.set(row.date, current);
|
||||
}));
|
||||
return calendarDays(startDate, endDate).map((date) => map.get(date) || { date, kg: 0, amt: 0, vehicles: 0 });
|
||||
}, [boardRows, startDate, endDate]);
|
||||
|
||||
/** 当前选中站点的区间日现结;接口成功后才把无进账日期显式为 0。 */
|
||||
const cashDaily = useMemo(() => {
|
||||
if (!stationCashBoard?.selected) return null;
|
||||
const map = new Map(stationCashBoard.selected.daily.map((row) => [row.date, row.paymentAmount]));
|
||||
return calendarDays(startDate, endDate).map((date) => ({ date, amount: map.get(date) || 0 }));
|
||||
}, [stationCashBoard, startDate, endDate]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setTick((n) => n + 1);
|
||||
};
|
||||
|
||||
const openStation = (id: string) => setDrillStationId(id);
|
||||
|
||||
if (drillStationId) {
|
||||
const detail = (
|
||||
<StationDailyDetailView
|
||||
stationId={drillStationId}
|
||||
asOf={endDate}
|
||||
rangeStart={startDate}
|
||||
rangeEnd={endDate}
|
||||
updatedAt={updatedAt}
|
||||
onBack={() => setDrillStationId(null)}
|
||||
onRefresh={handleRefresh}
|
||||
onRangeChange={({ start, end }) => {
|
||||
setStartDate(start);
|
||||
setEndDate(end);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
if (embedded) {
|
||||
return <div className="sd-embedded" data-annotation-id="energy-h2-station-daily-embedded">{detail}</div>;
|
||||
}
|
||||
return (
|
||||
<div className="ehb-shell ehb-shell--station-daily" data-annotation-id="energy-h2-station-daily">
|
||||
<div className="ehb-body sd-body">{detail}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasCash = boardRows.some((r) => r.cashDays > 0);
|
||||
const dayCount = calendarDays(startDate, endDate).length;
|
||||
|
||||
const cockpit = (
|
||||
<>
|
||||
<header className={`sd-topbar ${embedded ? 'sd-topbar--embedded' : ''}`}>
|
||||
{!embedded ? (
|
||||
<div className="sd-topbar__lead">
|
||||
<p className="sd-topbar__kicker">羚牛氢能 · 加氢站经营</p>
|
||||
<h1 className="sd-topbar__title">加氢站日报</h1>
|
||||
<p className="sd-topbar__updated">最后更新时间 {updatedAt}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{!embedded ? <div className="sd-topbar__tools">
|
||||
<SdDateRangePicker
|
||||
label="查询日期"
|
||||
start={startDate}
|
||||
end={endDate}
|
||||
anchorYmd={endDate}
|
||||
onChange={({ start, end }) => {
|
||||
setStartDate(start);
|
||||
setEndDate(end);
|
||||
}}
|
||||
/>
|
||||
<button type="button" className="sd-btn sd-btn--ghost" onClick={handleRefresh}>
|
||||
<RefreshCw size={15} aria-hidden />
|
||||
刷新
|
||||
</button>
|
||||
</div> : null}
|
||||
</header>
|
||||
|
||||
<section className="sd-mobile-operating-overview" aria-label="经营概览">
|
||||
<div className="sd-mobile-operating-overview__head">
|
||||
<div>
|
||||
<span>经营概览</span>
|
||||
<small>{startDate} 至 {endDate}</small>
|
||||
</div>
|
||||
<strong>{boardRows.length} 个站点</strong>
|
||||
</div>
|
||||
<div className="sd-mobile-operating-overview__source">
|
||||
数据来源:加氢业务账本;现结金额来自加氢站收款流水
|
||||
</div>
|
||||
<div className="sd-mobile-operating-overview__summary">
|
||||
<button type="button" className="sd-mobile-operating-overview__primary" onClick={() => setKpiDrill('volumeTotal')}>
|
||||
<span>统计加氢总量</span>
|
||||
<strong>{kg(totals.rangeKg)}<small> Kg</small></strong>
|
||||
<em><Fuel size={13} aria-hidden />{totals.endVehicles} 车次</em>
|
||||
</button>
|
||||
<div className="sd-mobile-operating-overview__financials">
|
||||
<div><span>统计金额</span><strong>¥{money(totals.rangeAmt)}</strong></div>
|
||||
<button type="button" onClick={() => setKpiDrill('cashDays')}>
|
||||
<span>现结金额</span><strong>¥{money(totals.cashTotal)}</strong>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="sd-hero-kpis" aria-label="全站核心指标">
|
||||
<div className="sd-hero-kpi">
|
||||
<div className="sd-hero-kpi__label">加氢站</div>
|
||||
<div className="sd-hero-kpi__value">{boardRows.length}</div>
|
||||
<div className="sd-hero-kpi__sub">统计站点数</div>
|
||||
<div className="sd-hero-kpi__source">来源:加氢业务账本(保留范围内无加氢记录的站点)<br />区间:{startDate} 至 {endDate}</div>
|
||||
</div>
|
||||
<button type="button" className="sd-hero-kpi sd-hero-kpi--accent sd-hero-kpi--click" onClick={() => setKpiDrill('volumeTotal')}>
|
||||
<div className="sd-hero-kpi__label">统计加氢总量</div>
|
||||
<div className="sd-hero-kpi__value">
|
||||
{kg(totals.rangeKg)}
|
||||
<span className="sd-unit">Kg</span>
|
||||
</div>
|
||||
<div className="sd-hero-kpi__sub">
|
||||
{totals.endVehicles} 车次 · 点按查看日明细
|
||||
</div>
|
||||
<div className="sd-hero-kpi__source">来源:加氢业务账本(有效加氢记录按加氢量求和)<br />区间:{startDate} 至 {endDate}</div>
|
||||
</button>
|
||||
<button type="button" className="sd-hero-kpi sd-hero-kpi--click" onClick={() => setKpiDrill('volumeDays')}>
|
||||
<div className="sd-hero-kpi__label">加氢车次</div>
|
||||
<div className="sd-hero-kpi__value">
|
||||
{totals.endVehicles}
|
||||
<span className="sd-unit">车次</span>
|
||||
</div>
|
||||
<div className="sd-hero-kpi__sub">{dayCount} 天 · 日均 {(totals.endVehicles / Math.max(dayCount, 1)).toFixed(1)} 车次</div>
|
||||
<div className="sd-hero-kpi__source">来源:加氢业务账本(有效记录数)<br />区间:{startDate} 至 {endDate}</div>
|
||||
</button>
|
||||
<button type="button" className="sd-hero-kpi sd-hero-kpi--click" onClick={() => setKpiDrill('cashDays')}>
|
||||
<div className="sd-hero-kpi__label">统计现结金额</div>
|
||||
<div className="sd-hero-kpi__value">
|
||||
{money(totals.cashTotal)}
|
||||
<span className="sd-unit">元</span>
|
||||
</div>
|
||||
<div className="sd-hero-kpi__sub">{cashDaily ? (hasCash ? `${cashDaily.filter((d) => d.amount > 0).length} 天有进账` : '0 天有进账') : '正在读取当前站点流水'}</div>
|
||||
<div className="sd-hero-kpi__source">来源:加氢站收款流水(已入账金额)<br />区间:{startDate} 至 {endDate}</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{liveLoading && !liveBoard ? (
|
||||
<div className="sd-live-loading-overlay" role="status" aria-live="polite">
|
||||
<span className="sd-live-loading-spinner" aria-hidden />
|
||||
<strong>正在读取单站真实统计数据</strong>
|
||||
<span>加载完成前不展示业务零值</span>
|
||||
</div>
|
||||
) : liveLoading ? <div className="ehb-empty">正在更新,暂时保留上一份有效数据…</div> : null}
|
||||
{liveError ? <div className="ehb-empty">单站统计加载失败:{liveError}</div> : null}
|
||||
|
||||
<section className="sd-station-board" aria-label="站点经营概况" data-mobile-fullscreen-list>
|
||||
<div className="sd-section-head">
|
||||
<h2 className="sd-section-title">站点经营概况</h2>
|
||||
<span className="sd-station-board__fullscreen">
|
||||
<MobileListFullscreenButton label="横屏全屏查看站点经营概况" />
|
||||
</span>
|
||||
<StationFilter value={stationFilter} rows={overviewRows} onChange={setStationFilter} />
|
||||
</div>
|
||||
|
||||
<div className="sd-station-single-card">
|
||||
{boardRows.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
className="sd-station-row is-solo"
|
||||
onClick={() => openStation(r.id)}
|
||||
>
|
||||
<div className="sd-station-row__main">
|
||||
<span className="sd-station-card__icon" aria-hidden>
|
||||
<Fuel size={18} />
|
||||
</span>
|
||||
<div className="sd-station-row__id">
|
||||
<div className="sd-station-card__name">{r.name}</div>
|
||||
{r.endVehicles === 0 ? <div className="sd-station-card__metric-source">当前期间无加氢记录</div> : null}
|
||||
<div className="sd-station-card__region">
|
||||
<MapPin size={12} aria-hidden />
|
||||
{r.region}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sd-station-row__metrics">
|
||||
<div>
|
||||
<span className="sd-station-card__m-label">加氢量</span>
|
||||
<strong>
|
||||
{kg(r.rangeKg)}
|
||||
<span>Kg</span>
|
||||
</strong>
|
||||
<small className="sd-station-card__metric-source">加氢业务账本 · {startDate} 至 {endDate}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span className="sd-station-card__m-label">车次</span>
|
||||
<strong>{r.endVehicles}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="sd-station-card__m-label">金额</span>
|
||||
<strong>¥{money(r.rangeAmt)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<StationTrend vols={r.vols} />
|
||||
<span className="sd-station-card__go" aria-hidden>
|
||||
<ArrowUpRight size={16} />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{kpiDrill ? (
|
||||
<div className="sd-kpi-modal" role="dialog" aria-modal="true">
|
||||
<button type="button" className="sd-kpi-modal__mask" aria-label="关闭" onClick={() => setKpiDrill(null)} />
|
||||
<div className="sd-kpi-modal__panel">
|
||||
<div className="sd-kpi-modal__head">
|
||||
<h3>
|
||||
{kpiDrill === 'volumeTotal' && '统计加氢总量 · 日明细'}
|
||||
{kpiDrill === 'volumeDays' && '统计加氢量 · 单日数据'}
|
||||
{kpiDrill === 'cashDays' && '统计现结金额 · 单日数据'}
|
||||
</h3>
|
||||
<span className="sd-kpi-modal__meta">
|
||||
{startDate} 至 {endDate}
|
||||
</span>
|
||||
<button type="button" className="sd-btn sd-btn--ghost" onClick={() => setKpiDrill(null)}>
|
||||
<X size={14} aria-hidden />
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<div className="sd-table-scroll">
|
||||
{kpiDrill === 'cashDays' && cashDaily ? (
|
||||
<table className="sd-bi-table sd-bi-table--fill">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>日期</th>
|
||||
<th className="is-num">现结金额(元)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cashDaily.map((d) => (
|
||||
<tr key={d.date}>
|
||||
<td className="is-mono">{d.date}</td>
|
||||
<td className="is-num">{money(d.amount)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : kpiDrill === 'cashDays' ? (
|
||||
<div className="ehb-empty" role="status">
|
||||
{stationCashError ? `当前站点现结流水加载失败:${stationCashError}` : '正在读取当前站点现结流水…'}
|
||||
</div>
|
||||
) : (
|
||||
<table className="sd-bi-table sd-bi-table--fill">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>日期</th>
|
||||
<th className="is-num">加氢车次</th>
|
||||
<th className="is-num">加氢量(Kg)</th>
|
||||
<th className="is-num">加氢金额(元)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dailyAgg.map((d) => (
|
||||
<tr key={d.date}>
|
||||
<td className="is-mono">{d.date}</td>
|
||||
<td className="is-num">{d.vehicles}</td>
|
||||
<td className="is-num">{kg(d.kg)}</td>
|
||||
<td className="is-num">{money(d.amt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return <div className="sd-embedded" data-annotation-id="energy-h2-station-daily-embedded">{cockpit}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ehb-shell ehb-shell--station-daily" data-annotation-id="energy-h2-station-daily">
|
||||
<div className="ehb-body sd-body">{cockpit}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+870
@@ -0,0 +1,870 @@
|
||||
// @ts-nocheck — verbatim 8113 prototype source; runtime DOM/CSS is intentionally preserved.
|
||||
/**
|
||||
* 单站日报明细 · 对齐汇报 Excel 精简表集
|
||||
* 近7日红涨绿跌 · 月环比同色 · 列表最多10条可展开
|
||||
*/
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ArrowLeft, ChevronDown, ChevronUp, Download, RefreshCw } from 'lucide-react';
|
||||
import { downloadExcelAoa } from '../../common/download-xls';
|
||||
import { MobileListFullscreenButton } from '../../common/MobileListFullscreenButton';
|
||||
import {
|
||||
SPOT_PAY_METHOD_LABEL,
|
||||
type StationCashIntakeDay,
|
||||
} from '../../common/energy-spot-cash-intake';
|
||||
import { fetchHydrogenStationBoard } from '../../../../modules/energy/api';
|
||||
import { fetchAllH2BiDrillRecords } from '../../../../modules/energy/hydrogen-bi-v2/api';
|
||||
import { PrototypeDrillModal } from '../../../../modules/energy/hydrogen-bi-v2/prototype-real-drills';
|
||||
import { MobileDailyList, MobileCustomerMonthList } from './StationMobileLists';
|
||||
import type { HydrogenStationBoardResponse } from '../../../../modules/energy/types';
|
||||
import {
|
||||
monthTotals,
|
||||
stationTrendDateLabel,
|
||||
} from './data/mockStationDaily';
|
||||
import { SdCustomerMultiSelect } from './SdCustomerMultiSelect';
|
||||
import { SdDateRangePicker } from './SdDateRangePicker';
|
||||
import { customerMonthLabel, customerMonthRange, dateRangeLabel } from './station-month-range';
|
||||
|
||||
const ROW_LIMIT = 10;
|
||||
|
||||
function money(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function kg(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function padYmd(raw: string) {
|
||||
const parts = String(raw || '').split(/[-/]/);
|
||||
if (parts.length < 3) return raw;
|
||||
const y = parts[0];
|
||||
const m = String(Number(parts[1]) || 0).padStart(2, '0');
|
||||
const d = String(Number(parts[2]) || 0).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
/** 股市色:升红 · 降绿 · 平灰 */
|
||||
function stockDeltaClass(curr: number, prev: number | null | undefined): string {
|
||||
if (prev == null || !Number.isFinite(prev)) return '';
|
||||
if (curr > prev) return 'is-stock-up';
|
||||
if (curr < prev) return 'is-stock-down';
|
||||
return 'is-stock-flat';
|
||||
}
|
||||
|
||||
/** 普通业务数值不使用涨跌色;仅零值弱化、负余额标记风险。 */
|
||||
function businessValueClass(n: number, negativeIsRisk = false): string {
|
||||
if (n === 0) return 'is-zero';
|
||||
if (negativeIsRisk && n < 0) return 'is-risk-negative';
|
||||
return '';
|
||||
}
|
||||
|
||||
function DeltaMark({ curr, prev }: { curr: number; prev: number | null | undefined }) {
|
||||
if (prev == null || !Number.isFinite(prev) || curr === prev) return null;
|
||||
return (
|
||||
<span className="sd-delta" aria-hidden>
|
||||
{curr > prev ? '▲' : '▼'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MoreToggle({
|
||||
expanded,
|
||||
total,
|
||||
onToggle,
|
||||
}: {
|
||||
expanded: boolean;
|
||||
total: number;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
if (total <= ROW_LIMIT) return null;
|
||||
return (
|
||||
<button type="button" className="sd-more-btn" onClick={onToggle}>
|
||||
{expanded ? (
|
||||
<>
|
||||
收起
|
||||
<ChevronUp size={14} aria-hidden />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
更多(还有 {total - ROW_LIMIT} 条)
|
||||
<ChevronDown size={14} aria-hidden />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export const StationDailyDetailView: React.FC<{
|
||||
stationId: string;
|
||||
asOf: string;
|
||||
rangeStart: string;
|
||||
rangeEnd: string;
|
||||
updatedAt: string;
|
||||
onBack: () => void;
|
||||
onRefresh: () => void;
|
||||
onRangeChange: (next: { start: string; end: string }) => void;
|
||||
}> = ({ stationId, asOf, rangeStart, rangeEnd, updatedAt, onBack, onRefresh, onRangeChange }) => {
|
||||
const [cashTick, setCashTick] = useState(0);
|
||||
const [selectedCustomers, setSelectedCustomers] = useState<string[]>([]);
|
||||
const [hoverDate, setHoverDate] = useState<string | null>(null);
|
||||
const [expandCust, setExpandCust] = useState(false);
|
||||
const [customerMonthlyMetric, setCustomerMonthlyMetric] = useState<'volume' | 'fee'>('volume');
|
||||
const [expandBal, setExpandBal] = useState(false);
|
||||
const [expandCash, setExpandCash] = useState(false);
|
||||
const [mobileDetailTab, setMobileDetailTab] = useState<'daily' | 'customer' | 'balance' | 'cash'>('daily');
|
||||
const [mobileMonthKey, setMobileMonthKey] = useState<string>('');
|
||||
const [orderDate, setOrderDate] = useState<string | null>(null);
|
||||
const [liveBoard, setLiveBoard] = useState<HydrogenStationBoardResponse | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
const exportController = useRef<AbortController | null>(null);
|
||||
const [liveError, setLiveError] = useState<string | null>(null);
|
||||
const [liveLoading, setLiveLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
exportController.current?.abort();
|
||||
exportController.current = null;
|
||||
setExporting(false);
|
||||
setExportError(null);
|
||||
setOrderDate(null);
|
||||
setLiveError(null);
|
||||
setLiveLoading(true);
|
||||
// 新的站点或日期范围不能沿用旧响应;否则月列已变而数值仍属上一查询。
|
||||
setLiveBoard(null);
|
||||
fetchHydrogenStationBoard({ startDate: rangeStart, endDate: rangeEnd, stationId: Number(stationId), force: cashTick > 0 }).then((board) => {
|
||||
if (!active) return;
|
||||
setLiveBoard(board);
|
||||
setLiveLoading(false);
|
||||
}).catch((reason: unknown) => {
|
||||
if (!active) return;
|
||||
setLiveError(reason instanceof Error ? reason.message : '站点详情加载失败');
|
||||
setLiveLoading(false);
|
||||
});
|
||||
return () => { active = false; exportController.current?.abort(); };
|
||||
}, [stationId, rangeStart, rangeEnd, updatedAt, cashTick]);
|
||||
|
||||
const liveStation = liveBoard?.stations.find((station) => String(station.id) === String(stationId));
|
||||
const stationName = liveStation?.name || stationId;
|
||||
const region = liveStation ? [liveStation.province, liveStation.city].filter(Boolean).join(' · ') : '';
|
||||
|
||||
const startDate = rangeStart;
|
||||
const end = rangeEnd;
|
||||
// 接口口径:截至查询结束日的近 12 个月;缺失月只补展示槽位,不新增业务数据。
|
||||
const customerMonthKeys = useMemo(() => customerMonthRange(end), [end]);
|
||||
|
||||
useEffect(() => {
|
||||
const latestMonth = customerMonthKeys[customerMonthKeys.length - 1] ?? '';
|
||||
if (!customerMonthKeys.includes(mobileMonthKey)) setMobileMonthKey(latestMonth);
|
||||
}, [customerMonthKeys, mobileMonthKey]);
|
||||
|
||||
const volumeRows = useMemo(
|
||||
() => (liveBoard?.selected?.daily ?? []).map((row) => ({
|
||||
date: row.date,
|
||||
stationId,
|
||||
quantityKg: row.kg,
|
||||
unitPrice: row.avgPrice,
|
||||
amountYuan: row.fee,
|
||||
vehicleCount: row.recordCount,
|
||||
})),
|
||||
[liveBoard, stationId],
|
||||
);
|
||||
|
||||
/** 近 7 日(按日期升序取末 7 条) */
|
||||
const volume7 = useMemo(() => {
|
||||
const sorted = [...volumeRows].sort((a, b) => a.date.localeCompare(b.date));
|
||||
return sorted.slice(-7);
|
||||
}, [volumeRows]);
|
||||
|
||||
const volume7Kg = volume7.reduce((s, r) => s + r.quantityKg, 0);
|
||||
const volume7Amt = volume7.reduce((s, r) => s + r.amountYuan, 0);
|
||||
const volume7Vehicles = volume7.reduce((s, r) => s + r.vehicleCount, 0);
|
||||
|
||||
const asOfVolume = volumeRows.find((r) => r.date === asOf) || volumeRows[volumeRows.length - 1];
|
||||
const rangeKg = volumeRows.reduce((s, r) => s + r.quantityKg, 0);
|
||||
const rangeAmt = volumeRows.reduce((s, r) => s + r.amountYuan, 0);
|
||||
const monthKg = volumeRows
|
||||
.filter((r) => r.date.startsWith(asOf.slice(0, 7)))
|
||||
.reduce((s, r) => s + r.quantityKg, 0);
|
||||
|
||||
const prevDayKg = useMemo(() => {
|
||||
if (!asOfVolume) return null;
|
||||
const sorted = [...volumeRows].sort((a, b) => a.date.localeCompare(b.date));
|
||||
const idx = sorted.findIndex((r) => r.date === asOfVolume.date);
|
||||
return idx > 0 ? sorted[idx - 1].quantityKg : null;
|
||||
}, [asOfVolume, volumeRows]);
|
||||
|
||||
const allCustCells = useMemo(
|
||||
() => {
|
||||
const byCustomer = new Map<string, Record<string, number>>();
|
||||
for (const row of liveBoard?.selected?.customerMonths ?? []) {
|
||||
const months = byCustomer.get(row.customerName) ?? {};
|
||||
const monthKey = String(row.month).slice(0, 7);
|
||||
months[monthKey] = row.kg;
|
||||
byCustomer.set(row.customerName, months);
|
||||
}
|
||||
return [...byCustomer].map(([customerName, months]) => ({ stationId, customerName, months }));
|
||||
},
|
||||
[liveBoard, stationId],
|
||||
);
|
||||
const customerOptions = useMemo(
|
||||
() => allCustCells.map((c) => c.customerName),
|
||||
[allCustCells],
|
||||
);
|
||||
|
||||
const custCells = useMemo(() => {
|
||||
if (selectedCustomers.length === 0) return allCustCells;
|
||||
const set = new Set(selectedCustomers);
|
||||
return allCustCells.filter((c) => set.has(c.customerName));
|
||||
}, [allCustCells, selectedCustomers]);
|
||||
|
||||
const allFeeCells = useMemo(() => {
|
||||
const byCustomer = new Map<string, Record<string, number>>();
|
||||
for (const row of liveBoard?.selected?.customerMonths ?? []) {
|
||||
const months = byCustomer.get(row.customerName) ?? {};
|
||||
const monthKey = String(row.month).slice(0, 7);
|
||||
months[monthKey] = row.fee;
|
||||
byCustomer.set(row.customerName, months);
|
||||
}
|
||||
return [...byCustomer].map(([customerName, months]) => ({ stationId, customerName, months }));
|
||||
}, [liveBoard, stationId]);
|
||||
const feeCells = useMemo(() => {
|
||||
const allowed = new Set(custCells.map((row) => row.customerName));
|
||||
return allFeeCells.filter((row) => allowed.has(row.customerName));
|
||||
}, [custCells, allFeeCells]);
|
||||
const kgMonthTot = useMemo(() => monthTotals(custCells, customerMonthKeys), [custCells, customerMonthKeys]);
|
||||
const feeMonthTot = useMemo(() => monthTotals(feeCells, customerMonthKeys), [feeCells, customerMonthKeys]);
|
||||
|
||||
const custVisible = expandCust ? custCells : custCells.slice(0, ROW_LIMIT);
|
||||
const feeVisible = expandCust ? feeCells : feeCells.slice(0, ROW_LIMIT);
|
||||
|
||||
const cashDays: StationCashIntakeDay[] = useMemo(() => {
|
||||
void cashTick;
|
||||
return (liveBoard?.selected?.daily ?? []).filter((row) => row.paymentAmount > 0).map((row) => ({
|
||||
id: `payment-${stationId}-${row.date}`,
|
||||
stationId,
|
||||
stationName,
|
||||
bizDate: row.date,
|
||||
totalAmount: row.paymentAmount,
|
||||
lines: [{ id: `payment-line-${stationId}-${row.date}`, customerName: '站点现结汇总', amount: row.paymentAmount, payMethod: 'other' }],
|
||||
updatedBy: '只读账本',
|
||||
updatedAt,
|
||||
}));
|
||||
}, [liveBoard, stationId, stationName, updatedAt, cashTick]);
|
||||
|
||||
const cashLines = useMemo(
|
||||
() =>
|
||||
cashDays.flatMap((d) =>
|
||||
d.lines.map((l) => ({
|
||||
id: l.id,
|
||||
bizDate: padYmd(d.bizDate),
|
||||
customerName: l.customerName,
|
||||
payMethod: l.payMethod,
|
||||
amount: l.amount,
|
||||
})),
|
||||
),
|
||||
[cashDays],
|
||||
);
|
||||
const cashVisible = expandCash ? cashLines : cashLines.slice(0, ROW_LIMIT);
|
||||
|
||||
const cashTotal = cashDays.reduce((s, d) => s + d.totalAmount, 0);
|
||||
const balanceRows = useMemo(
|
||||
() => [],
|
||||
[],
|
||||
);
|
||||
const balVisible = expandBal ? balanceRows : balanceRows.slice(0, ROW_LIMIT);
|
||||
const balanceSubtotal = useMemo(
|
||||
() => ({
|
||||
recharge: balanceRows.reduce((s, b) => s + b.rechargeOrSpotYuan, 0),
|
||||
prepaid: balanceRows.reduce((s, b) => s + b.consumePrepaidYuan, 0),
|
||||
spot: balanceRows.reduce((s, b) => s + b.consumeSpotYuan, 0),
|
||||
balance: balanceRows.reduce((s, b) => s + b.balanceYuan, 0),
|
||||
}),
|
||||
[balanceRows],
|
||||
);
|
||||
const maxKg = Math.max(...volumeRows.map((r) => r.quantityKg), 1);
|
||||
const hoverRow = hoverDate ? volumeRows.find((r) => r.date === hoverDate) : null;
|
||||
|
||||
const handleExport = async () => {
|
||||
if (liveLoading || liveError || !liveBoard || exportController.current) return;
|
||||
const controller = new AbortController();
|
||||
exportController.current = controller;
|
||||
setExporting(true);
|
||||
setExportError(null);
|
||||
try {
|
||||
const result = await fetchAllH2BiDrillRecords({
|
||||
year: Number(rangeStart.slice(0, 4)), startDate: rangeStart, endDate: rangeEnd,
|
||||
vehicleScope: 'all', verifyScope: 'all', stationId,
|
||||
}, { signal: controller.signal });
|
||||
if (controller.signal.aborted) return;
|
||||
const vehicleRows = result.records.map((row) => ({
|
||||
date: String(row.time ?? '').slice(0, 10),
|
||||
plateNo: String(row.plateNo ?? '未关联车牌'),
|
||||
customerName: String(row.customerName ?? '未关联客户'),
|
||||
fleet: row.vehicleScope === 'lingniu' ? 'own' : 'external',
|
||||
quantityKg: Number(row.kg) || 0,
|
||||
unitPrice: Number(row.unitPrice) || 0,
|
||||
amountYuan: Number(row.cost) || 0,
|
||||
}));
|
||||
const monthHeads = customerMonthKeys.map(customerMonthLabel);
|
||||
const aoa: (string | number)[][] = [
|
||||
[`${stationName} · 站日报(查询日期 ${asOf})`],
|
||||
['统计区间', `${startDate} 至 ${end}`],
|
||||
['最后更新时间', updatedAt],
|
||||
[],
|
||||
['近7日加氢量'],
|
||||
['日期', '加氢量(Kg)', '单价', '金额(元)', '车次'],
|
||||
...volume7.map((r) => [r.date, r.quantityKg, r.unitPrice, r.amountYuan, r.vehicleCount]),
|
||||
[],
|
||||
['客户月加氢量(Kg)'],
|
||||
['客户', ...monthHeads],
|
||||
...custCells.map((c) => [
|
||||
c.customerName,
|
||||
...customerMonthKeys.map((m) => c.months[m] ?? 0),
|
||||
]),
|
||||
[],
|
||||
['客户月加氢费(元)'],
|
||||
['客户', ...monthHeads],
|
||||
...feeCells.map((c) => [
|
||||
c.customerName,
|
||||
...customerMonthKeys.map((m) => c.months[m] ?? 0),
|
||||
]),
|
||||
[],
|
||||
['客户氢费收支汇总'],
|
||||
['客户', '充值/现金结算', '扣预付消费', '现结消费', '余额', '备注'],
|
||||
...balanceRows.map((b) => [
|
||||
b.customerName,
|
||||
b.rechargeOrSpotYuan,
|
||||
b.consumePrepaidYuan,
|
||||
b.consumeSpotYuan,
|
||||
b.balanceYuan,
|
||||
b.remark || '',
|
||||
]),
|
||||
['小计', balanceSubtotal.recharge, balanceSubtotal.prepaid, balanceSubtotal.spot, balanceSubtotal.balance, ''],
|
||||
[],
|
||||
['氢费充值/现结进账明细'],
|
||||
['充值日期', '客户', '付款方式', '金额(元)'],
|
||||
...cashLines.map((l) => [l.bizDate, l.customerName, SPOT_PAY_METHOD_LABEL[l.payMethod], l.amount]),
|
||||
[],
|
||||
['车辆加氢明细(查询区间全部真实账本记录)', result.records.length],
|
||||
['日期', '车牌', '客户', '归属', '加氢量(Kg)', '单价', '金额(元)'],
|
||||
...vehicleRows.map((r) => [
|
||||
r.date,
|
||||
r.plateNo,
|
||||
r.customerName,
|
||||
r.fleet === 'own' ? '羚牛车辆' : '外部车辆',
|
||||
r.quantityKg,
|
||||
r.unitPrice,
|
||||
r.amountYuan,
|
||||
]),
|
||||
];
|
||||
downloadExcelAoa(aoa, `站日报取证_${stationName}_查询${asOf}.xlsx`, '站日报取证');
|
||||
} catch (reason) {
|
||||
if (!controller.signal.aborted) setExportError(reason instanceof Error ? reason.message : '导出失败,请重试');
|
||||
} finally {
|
||||
if (exportController.current === controller) {
|
||||
exportController.current = null;
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderMonthCells = (
|
||||
months: Record<string, number>,
|
||||
fmt: (n: number) => string,
|
||||
) =>
|
||||
customerMonthKeys.map((m, i) => {
|
||||
const curr = months[m] ?? 0;
|
||||
const prevKey = i > 0 ? customerMonthKeys[i - 1] : null;
|
||||
const prev = prevKey != null ? (months[prevKey] ?? 0) : null;
|
||||
const cls = stockDeltaClass(curr, prev);
|
||||
const zeroClass = curr === 0 ? 'is-zero' : '';
|
||||
const currentMonthClass =
|
||||
i === customerMonthKeys.length - 1 ? 'is-current-month' : '';
|
||||
return (
|
||||
<td key={m} className={`is-num ${cls} ${zeroClass} ${currentMonthClass}`}>
|
||||
{fmt(curr)}
|
||||
<DeltaMark curr={curr} prev={prev} />
|
||||
</td>
|
||||
);
|
||||
});
|
||||
|
||||
if (liveLoading || liveError) {
|
||||
return (
|
||||
<div className="sd-detail" data-annotation-id="station-daily-detail">
|
||||
<header className="sd-detail-top">
|
||||
<div className="sd-detail-top__lead">
|
||||
<button type="button" className="sd-btn sd-btn--ghost" onClick={onBack} aria-label="返回上一级">
|
||||
<ArrowLeft size={15} aria-hidden />
|
||||
返回
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="sd-detail-top__title">{stationName}</h1>
|
||||
<p className="sd-detail-top__meta">{startDate} 至 {end}</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="ehb-empty" role={liveError ? 'alert' : 'status'}>
|
||||
{liveError ? `站点详情加载失败:${liveError}` : '正在读取当前站点真实统计数据,完成前不展示业务零值'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sd-detail sd-detail--mobile-pilot" data-annotation-id="station-daily-detail">
|
||||
<header className="sd-detail-top">
|
||||
<div className="sd-detail-top__lead">
|
||||
<button type="button" className="sd-btn sd-btn--ghost" onClick={onBack}>
|
||||
<ArrowLeft size={15} aria-hidden />
|
||||
返回
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="sd-detail-top__title">{stationName}</h1>
|
||||
<p className="sd-detail-top__meta">
|
||||
{region ? `${region} · ` : ''}
|
||||
{startDate} 至 {end}
|
||||
</p>
|
||||
<p className="sd-detail-top__updated">最后更新时间 {updatedAt}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sd-detail-top__tools">
|
||||
<SdDateRangePicker
|
||||
label="查询日期"
|
||||
start={rangeStart}
|
||||
end={rangeEnd}
|
||||
anchorYmd={asOf}
|
||||
onChange={onRangeChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="sd-btn sd-btn--ghost"
|
||||
onClick={() => {
|
||||
onRefresh();
|
||||
setCashTick((n) => n + 1);
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={16} aria-hidden />
|
||||
刷新
|
||||
</button>
|
||||
<button type="button" className="sd-btn sd-btn--primary ehb-hide-h5" disabled={liveLoading || Boolean(liveError) || exporting} onClick={handleExport}>
|
||||
<Download size={15} aria-hidden />
|
||||
{exporting ? '正在读取全部记录…' : '导出取证'}
|
||||
</button>
|
||||
{exporting ? <button type="button" className="sd-btn sd-btn--ghost" onClick={() => exportController.current?.abort()}>取消导出</button> : null}
|
||||
</div>
|
||||
</header>
|
||||
{exportError ? <div role="alert" className="ehb-empty">导出失败:{exportError}。未生成文件,请缩小日期范围或重试。</div> : null}
|
||||
|
||||
{liveError ? (
|
||||
<div className="ehb-empty" role="alert">站点详情加载失败:{liveError}</div>
|
||||
) : null}
|
||||
|
||||
<div className="sd-hero-kpis sd-hero-kpis--detail">
|
||||
<div className="sd-hero-kpi sd-hero-kpi--accent">
|
||||
<div className="sd-hero-kpi__label">当日加氢</div>
|
||||
<div className={`sd-hero-kpi__value ${stockDeltaClass(asOfVolume?.quantityKg ?? 0, prevDayKg)}`}>
|
||||
{asOfVolume ? kg(asOfVolume.quantityKg) : '0.00'}
|
||||
<span className="sd-unit">Kg</span>
|
||||
<DeltaMark curr={asOfVolume?.quantityKg ?? 0} prev={prevDayKg} />
|
||||
</div>
|
||||
<div className="sd-hero-kpi__sub">
|
||||
{asOfVolume
|
||||
? `¥${money(asOfVolume.amountYuan)} · ${asOfVolume.vehicleCount} 车次`
|
||||
: '¥0.00 · 0 车次'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sd-hero-kpi">
|
||||
<div className="sd-hero-kpi__label">查询区间加氢</div>
|
||||
<div className="sd-hero-kpi__value">
|
||||
{kg(rangeKg)}
|
||||
<span className="sd-unit">Kg</span>
|
||||
</div>
|
||||
<div className="sd-hero-kpi__sub">¥{money(rangeAmt)} · {dateRangeLabel(startDate, end)}</div>
|
||||
</div>
|
||||
<div className="sd-hero-kpi">
|
||||
<div className="sd-hero-kpi__label">区间内本月加氢</div>
|
||||
<div className="sd-hero-kpi__value">
|
||||
{kg(monthKg)}
|
||||
<span className="sd-unit">Kg</span>
|
||||
</div>
|
||||
<div className="sd-hero-kpi__sub">{asOf.slice(0, 7)} · 仅计入查询区间</div>
|
||||
</div>
|
||||
<div className="sd-hero-kpi">
|
||||
<div className="sd-hero-kpi__label">查询区间现结</div>
|
||||
<div className="sd-hero-kpi__value">
|
||||
{money(cashTotal)}
|
||||
<span className="sd-unit">元</span>
|
||||
</div>
|
||||
<div className="sd-hero-kpi__sub">
|
||||
{cashDays.length ? `${cashDays.length} 天有进账 · ${dateRangeLabel(startDate, end)}` : `0 天有进账 · ${dateRangeLabel(startDate, end)}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sd-mobile-detail-hub" data-mobile-fullscreen-list>
|
||||
<header className="sd-mobile-detail-tabs">
|
||||
<div className="sd-mobile-detail-tabs__title">
|
||||
<span>经营明细</span>
|
||||
<MobileListFullscreenButton label="横屏全屏查看经营明细" placement="inline" />
|
||||
</div>
|
||||
<div className="sd-mobile-detail-tabs__rail" role="tablist" aria-label="经营明细类型">
|
||||
{([
|
||||
['daily', '日加氢'],
|
||||
['customer', '客户月度'],
|
||||
['balance', '收支'],
|
||||
['cash', '进账'],
|
||||
] as const).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mobileDetailTab === key}
|
||||
className={mobileDetailTab === key ? 'is-active' : ''}
|
||||
onClick={() => setMobileDetailTab(key)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className={`sd-panel sd-panel--block sd-mobile-detail-panel ${mobileDetailTab === 'daily' ? 'is-active' : ''}`} data-mobile-fullscreen-list>
|
||||
<h2 className="sd-panel__title">加氢站每日加氢量汇总(近 7 日)</h2>
|
||||
<MobileDailyList key={`${stationId}|${rangeStart}|${rangeEnd}`}
|
||||
rows={volume7} allRows={volumeRows} loading={liveLoading} error={liveError}
|
||||
onOpenOrders={setOrderDate} />
|
||||
<div className="sd-table-scroll">
|
||||
<table className="sd-bi-table sd-bi-table--fill">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>日期</th>
|
||||
<th className="is-num">加氢量(Kg)</th>
|
||||
<th className="is-num">较昨日</th>
|
||||
<th className="is-num">单价</th>
|
||||
<th className="is-num">金额(元)</th>
|
||||
<th className="is-num">车次</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="is-total">
|
||||
<td>近 7 日合计</td>
|
||||
<td className="is-num">{kg(volume7Kg)}</td>
|
||||
<td className="is-num">0</td>
|
||||
<td className="is-num">0</td>
|
||||
<td className="is-num">{money(volume7Amt)}</td>
|
||||
<td className="is-num">{volume7Vehicles}</td>
|
||||
</tr>
|
||||
{volume7.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="ehb-empty-cell">
|
||||
本窗暂无加氢量
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
volume7.map((r, i) => {
|
||||
const prev = i > 0 ? volume7[i - 1].quantityKg : null;
|
||||
// 较昨日:用完整序列前一日更准
|
||||
const fullIdx = volumeRows.findIndex((x) => x.date === r.date);
|
||||
const prevFull =
|
||||
fullIdx > 0 ? volumeRows[fullIdx - 1].quantityKg : prev;
|
||||
const diff = prevFull == null ? null : r.quantityKg - prevFull;
|
||||
const cls = stockDeltaClass(r.quantityKg, prevFull);
|
||||
return (
|
||||
<tr key={r.date}>
|
||||
<td>{padYmd(r.date)}</td>
|
||||
<td className={`is-num ${cls}`}>
|
||||
{kg(r.quantityKg)}
|
||||
<DeltaMark curr={r.quantityKg} prev={prevFull} />
|
||||
</td>
|
||||
<td className={`is-num ${cls}`}>
|
||||
{diff == null ? '0.00' : `${diff > 0 ? '+' : ''}${kg(diff)}`}
|
||||
</td>
|
||||
<td className="is-num">{r.unitPrice}</td>
|
||||
<td className="is-num">{money(r.amountYuan)}</td>
|
||||
<td className="is-num">{r.vehicleCount}</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="sd-panel sd-panel--block sd-mobile-trend-panel">
|
||||
<div className="sd-panel__head-row">
|
||||
<h2 className="sd-panel__title">区间加氢量趋势</h2>
|
||||
<span className="sd-trend-legend-chip" aria-hidden>
|
||||
<i className="sd-trend-legend-dot" />
|
||||
{stationName}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="sd-trend sd-trend--fill"
|
||||
role="img"
|
||||
aria-label="区间加氢量趋势"
|
||||
onMouseLeave={() => setHoverDate(null)}
|
||||
>
|
||||
{volumeRows.length === 0 ? (
|
||||
<div className="ehb-empty-cell" style={{ padding: 24 }}>
|
||||
本窗暂无趋势
|
||||
</div>
|
||||
) : (
|
||||
volumeRows.map((r) => (
|
||||
<div
|
||||
key={r.date}
|
||||
className={`sd-trend__col ${hoverDate === r.date ? 'is-hover' : ''}`}
|
||||
onMouseEnter={() => setHoverDate(r.date)}
|
||||
>
|
||||
<div className="sd-trend__val">{r.quantityKg.toFixed(0)}</div>
|
||||
<div className="sd-trend__bar-wrap">
|
||||
<div
|
||||
className="sd-trend__bar"
|
||||
style={{ height: `${Math.max(8, (r.quantityKg / maxKg) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="sd-trend__date">
|
||||
<span className="sd-trend__date--desktop">{stationTrendDateLabel(r.date)}</span>
|
||||
<span className="sd-trend__date--mobile">{stationTrendDateLabel(r.date).slice(5)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{hoverRow ? (
|
||||
<div className="sd-trend-tip" role="tooltip">
|
||||
<div className="sd-trend-tip__date">{padYmd(hoverRow.date)}</div>
|
||||
<div className="sd-trend-tip__row">
|
||||
<i className="sd-trend-legend-dot" />
|
||||
<span className="sd-trend-tip__name">{stationName}</span>
|
||||
<strong>
|
||||
{kg(hoverRow.quantityKg)} Kg · ¥{money(hoverRow.amountYuan)}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="sd-trend-tip__sub">{hoverRow.vehicleCount} 车次</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={`sd-panel sd-panel--block sd-mobile-detail-panel ${mobileDetailTab === 'customer' ? 'is-active' : ''}`} data-mobile-fullscreen-list>
|
||||
<div className="sd-panel__head-row">
|
||||
<div className="sd-customer-month-head">
|
||||
<h2 className="sd-panel__title">客户月度汇总(近 12 个月)</h2>
|
||||
<div className="sd-customer-month-tabs" role="tablist" aria-label="客户月度汇总指标">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={customerMonthlyMetric === 'volume'}
|
||||
className={customerMonthlyMetric === 'volume' ? 'is-active' : ''}
|
||||
onClick={() => setCustomerMonthlyMetric('volume')}
|
||||
>
|
||||
加氢量(Kg)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={customerMonthlyMetric === 'fee'}
|
||||
className={customerMonthlyMetric === 'fee' ? 'is-active' : ''}
|
||||
onClick={() => setCustomerMonthlyMetric('fee')}
|
||||
>
|
||||
加氢费(元)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<SdCustomerMultiSelect
|
||||
options={customerOptions}
|
||||
value={selectedCustomers}
|
||||
onChange={setSelectedCustomers}
|
||||
/>
|
||||
</div>
|
||||
<MobileCustomerMonthList key={stationId} months={customerMonthKeys}
|
||||
volumeCustomers={allCustCells} feeCustomers={allFeeCells}
|
||||
month={mobileMonthKey} onMonthChange={setMobileMonthKey}
|
||||
metric={customerMonthlyMetric} onMetricChange={setCustomerMonthlyMetric}
|
||||
loading={liveLoading} error={liveError} />
|
||||
<div className="sd-table-scroll sd-table-scroll--matrix sd-desktop-matrix-table">
|
||||
<table className={`sd-bi-table sd-bi-table--matrix ${customerMonthlyMetric === 'fee' ? 'is-amount' : ''}`}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>客户</th>
|
||||
{customerMonthKeys.map((m, i) => (
|
||||
<th
|
||||
key={m}
|
||||
className={`is-num ${i === customerMonthKeys.length - 1 ? 'is-current-month' : ''}`}
|
||||
>
|
||||
{customerMonthLabel(m)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="is-total">
|
||||
<td>合计</td>
|
||||
{customerMonthlyMetric === 'volume'
|
||||
? renderMonthCells(kgMonthTot, kg)
|
||||
: renderMonthCells(feeMonthTot, money)}
|
||||
</tr>
|
||||
{(customerMonthlyMetric === 'volume' ? custCells : feeCells).length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={1 + customerMonthKeys.length} className="ehb-empty-cell">
|
||||
无匹配客户
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
(customerMonthlyMetric === 'volume' ? custVisible : feeVisible).map((c) => (
|
||||
<tr key={c.customerName}>
|
||||
<td>{c.customerName}</td>
|
||||
{renderMonthCells(c.months, customerMonthlyMetric === 'volume' ? kg : money)}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="sd-table-scroll sd-mobile-combined-fullscreen-table">
|
||||
<table className="sd-bi-table sd-bi-table--ledger">
|
||||
<thead><tr><th>客户</th><th className="is-num">加氢量(Kg)</th><th className="is-num">加氢费(元)</th></tr></thead>
|
||||
<tbody>
|
||||
{custVisible.map((customer) => {
|
||||
const feeCustomer = feeCells.find((item) => item.customerName === customer.customerName);
|
||||
const volumeValue = customer.months[mobileMonthKey] ?? 0;
|
||||
const feeValue = feeCustomer?.months[mobileMonthKey] ?? 0;
|
||||
return <tr key={customer.customerName}><td>{customer.customerName}</td><td className={`is-num ${businessValueClass(volumeValue)}`}>{kg(volumeValue)}</td><td className={`is-num ${businessValueClass(feeValue)}`}>{money(feeValue)}</td></tr>;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<MoreToggle
|
||||
expanded={expandCust}
|
||||
total={(customerMonthlyMetric === 'volume' ? custCells : feeCells).length}
|
||||
onToggle={() => setExpandCust((v) => !v)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="sd-dual sd-dual--cash sd-dual--ledger">
|
||||
<section className={`sd-panel sd-mobile-detail-panel ${mobileDetailTab === 'balance' ? 'is-active' : ''}`} data-mobile-fullscreen-list>
|
||||
<h2 className="sd-panel__title">客户氢费收支汇总</h2>
|
||||
<div className="sd-mobile-record-list sd-mobile-balance-list">
|
||||
{balVisible.map((customer) => (
|
||||
<article key={customer.customerName} className="sd-mobile-record">
|
||||
<div className="sd-mobile-record__lead"><strong>{customer.customerName}</strong><span>{customer.remark || '账户正常'}</span></div>
|
||||
<div className="sd-mobile-record__value"><strong className={businessValueClass(customer.balanceYuan, true)}>¥{money(customer.balanceYuan)}</strong><span>余额</span></div>
|
||||
<div className="sd-mobile-record__meta">充值 ¥{money(customer.rechargeOrSpotYuan)} · 扣预付 ¥{money(customer.consumePrepaidYuan)} · 现结 ¥{money(customer.consumeSpotYuan)}</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<div className="sd-table-scroll">
|
||||
<table className="sd-bi-table sd-bi-table--ledger">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>客户</th>
|
||||
<th className="is-num">充值/现金结算</th>
|
||||
<th className="is-num">扣预付</th>
|
||||
<th className="is-num">现结</th>
|
||||
<th className="is-num">余额</th>
|
||||
<th>备注</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{balanceRows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="ehb-empty-cell">
|
||||
暂无收支汇总
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
<>
|
||||
{balVisible.map((b) => (
|
||||
<tr key={b.customerName}>
|
||||
<td title={b.customerName}>{b.customerName}</td>
|
||||
<td className={`is-num ${businessValueClass(b.rechargeOrSpotYuan)}`}>
|
||||
{money(b.rechargeOrSpotYuan)}
|
||||
</td>
|
||||
<td className={`is-num ${businessValueClass(b.consumePrepaidYuan)}`}>
|
||||
{money(b.consumePrepaidYuan)}
|
||||
</td>
|
||||
<td className={`is-num ${businessValueClass(b.consumeSpotYuan)}`}>
|
||||
{money(b.consumeSpotYuan)}
|
||||
</td>
|
||||
<td className={`is-num ${businessValueClass(b.balanceYuan, true)}`}>
|
||||
{money(b.balanceYuan)}
|
||||
</td>
|
||||
<td>{b.remark || ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="is-total">
|
||||
<td>小计</td>
|
||||
<td className={`is-num ${businessValueClass(balanceSubtotal.recharge)}`}>
|
||||
{money(balanceSubtotal.recharge)}
|
||||
</td>
|
||||
<td className={`is-num ${businessValueClass(balanceSubtotal.prepaid)}`}>{money(balanceSubtotal.prepaid)}</td>
|
||||
<td className={`is-num ${businessValueClass(balanceSubtotal.spot)}`}>{money(balanceSubtotal.spot)}</td>
|
||||
<td className={`is-num ${businessValueClass(balanceSubtotal.balance, true)}`}>
|
||||
{money(balanceSubtotal.balance)}
|
||||
</td>
|
||||
<td />
|
||||
</tr>
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<MoreToggle expanded={expandBal} total={balanceRows.length} onToggle={() => setExpandBal((v) => !v)} />
|
||||
</section>
|
||||
|
||||
<section className={`sd-panel sd-mobile-detail-panel ${mobileDetailTab === 'cash' ? 'is-active' : ''}`} data-mobile-fullscreen-list>
|
||||
<div className="sd-panel__head-row">
|
||||
<h2 className="sd-panel__title">氢费充值/现结进账明细</h2>
|
||||
<span className="sd-panel__meta">合计 ¥{money(cashTotal)} · {cashLines.length} 笔</span>
|
||||
</div>
|
||||
<div className="sd-mobile-record-list sd-mobile-cash-list">
|
||||
{cashVisible.map((line) => (
|
||||
<article key={line.id} className="sd-mobile-record">
|
||||
<div className="sd-mobile-record__lead"><strong>{line.customerName}</strong><span>{line.bizDate}</span></div>
|
||||
<div className="sd-mobile-record__value"><strong className={businessValueClass(line.amount)}>¥{money(line.amount)}</strong><span className="sd-mobile-pay-tag">{SPOT_PAY_METHOD_LABEL[line.payMethod]}</span></div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<div className="sd-table-scroll sd-table-scroll--cash-lines">
|
||||
<table className="sd-bi-table sd-bi-table--ledger">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>充值日期</th>
|
||||
<th>客户</th>
|
||||
<th>付款方式</th>
|
||||
<th className="is-num">金额(元)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cashLines.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="ehb-empty-cell">
|
||||
本窗暂无进账明细
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
cashVisible.map((l) => (
|
||||
<tr key={l.id}>
|
||||
<td className="is-mono">{l.bizDate}</td>
|
||||
<td title={l.customerName}>{l.customerName}</td>
|
||||
<td>{SPOT_PAY_METHOD_LABEL[l.payMethod]}</td>
|
||||
<td className={`is-num ${businessValueClass(l.amount)}`}>{money(l.amount)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<MoreToggle expanded={expandCash} total={cashLines.length} onToggle={() => setExpandCash((v) => !v)} />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{orderDate ? <PrototypeDrillModal kind="records"
|
||||
label={`${stationName} · ${orderDate} 加氢订单`}
|
||||
query={{ year: Number(orderDate.slice(0, 4)), startDate: orderDate, endDate: orderDate,
|
||||
date: orderDate, stationId, vehicleScope: 'all', verifyScope: 'all' }}
|
||||
onClose={() => setOrderDate(null)} /> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import './station-mobile-lists.css';
|
||||
import {
|
||||
customerMonthTotal,
|
||||
customerMetricValue,
|
||||
formatKg,
|
||||
formatMoney,
|
||||
mobileCustomerRows,
|
||||
mobileDailyRows,
|
||||
monthLabel,
|
||||
type CustomerMetric,
|
||||
type StationDailyVolumeRow,
|
||||
type StationMonthlyCustomer,
|
||||
} from './station-mobile-list-model';
|
||||
|
||||
export type { CustomerMetric, StationDailyVolumeRow, StationMonthlyCustomer } from './station-mobile-list-model';
|
||||
|
||||
export interface MobileDailyListProps {
|
||||
/** The selected near-seven-day rows; `allRows` remains the source for actual prior-day comparison. */
|
||||
rows: StationDailyVolumeRow[];
|
||||
allRows: StationDailyVolumeRow[];
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
onOpenOrders: (date: string) => void;
|
||||
}
|
||||
|
||||
export function MobileDailyList({ rows, allRows, loading = false, error = null, onOpenOrders }: MobileDailyListProps) {
|
||||
const dailyRows = useMemo(() => mobileDailyRows(rows, allRows), [rows, allRows]);
|
||||
if (loading) return <section className="sd-mobile-list-pilot" aria-busy="true">日报正在加载…</section>;
|
||||
if (error) return <section className="sd-mobile-list-pilot sd-mobile-list__state is-error" role="alert">日报加载失败:{error}</section>;
|
||||
if (!dailyRows.length) return <section className="sd-mobile-list-pilot sd-mobile-list__state">暂无日报数据</section>;
|
||||
|
||||
return <section className="sd-mobile-list-pilot" aria-label="近七日日报">
|
||||
<p className="sd-mobile-list__hint">最新日期在前 · 点日期展开车次、单价和订单</p>
|
||||
<div className="sd-mobile-list__head sd-mobile-daily__head"><span>日期</span><span>加氢量</span><span>金额</span></div>
|
||||
<div className="sd-mobile-list__rows">
|
||||
{dailyRows.map((row) => {
|
||||
const hasRecord = row.vehicleCount > 0;
|
||||
const delta = row.previousKg == null ? '无前日数据' : `${row.quantityKg - row.previousKg >= 0 ? '+' : ''}${formatKg(row.quantityKg - row.previousKg)} kg`;
|
||||
return <details className="sd-mobile-list__detail" key={row.date}>
|
||||
<summary className="sd-mobile-daily__row">
|
||||
<span><strong>{row.date.slice(5)}</strong><small>{row.date.slice(0, 4)}</small></span>
|
||||
<span className={hasRecord ? '' : 'is-zero'}><strong>{formatKg(row.quantityKg)}</strong><small>kg</small></span>
|
||||
<span className={hasRecord ? '' : 'is-zero'}><strong>¥{formatMoney(row.amountYuan)}</strong>{!hasRecord && <small>无记录</small>}</span>
|
||||
</summary>
|
||||
<div className="sd-mobile-list__expanded">
|
||||
<span>车次:{row.vehicleCount} 次</span><span>单价:¥{formatMoney(row.unitPrice)}/kg</span><span>较昨日:{delta}</span>
|
||||
<button type="button" onClick={() => onOpenOrders(row.date)}>查看当天订单</button>
|
||||
</div>
|
||||
</details>;
|
||||
})}
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
export interface MobileCustomerMonthListProps {
|
||||
/** Continuous YYYY-MM values, including months with no business records. */
|
||||
months: string[];
|
||||
volumeCustomers: StationMonthlyCustomer[];
|
||||
feeCustomers: StationMonthlyCustomer[];
|
||||
month: string;
|
||||
onMonthChange: (month: string) => void;
|
||||
metric: CustomerMetric;
|
||||
onMetricChange: (metric: CustomerMetric) => void;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export function MobileCustomerMonthList({ months, volumeCustomers, feeCustomers, month, onMonthChange, metric, onMetricChange, loading = false, error = null }: MobileCustomerMonthListProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const customers = metric === 'volume' ? volumeCustomers : feeCustomers;
|
||||
const sortedCustomers = useMemo(() => mobileCustomerRows(customers, month, search), [customers, month, search]);
|
||||
const total = useMemo(() => customerMonthTotal(customers, month), [customers, month]);
|
||||
if (loading) return <section className="sd-mobile-list-pilot" aria-busy="true">客户月度数据正在加载…</section>;
|
||||
if (error) return <section className="sd-mobile-list-pilot sd-mobile-list__state is-error" role="alert">客户月度加载失败:{error}</section>;
|
||||
|
||||
return <section className="sd-mobile-list-pilot" aria-label="客户月度排行">
|
||||
<div className="sd-mobile-customer__controls">
|
||||
<select aria-label="选择月份" value={month} onChange={(event) => onMonthChange(event.target.value)}>
|
||||
{months.map((item) => <option value={item} key={item}>{monthLabel(item)}</option>)}
|
||||
</select>
|
||||
<div className="sd-mobile-customer__metric" aria-label="选择指标">
|
||||
<button type="button" aria-pressed={metric === 'volume'} className={metric === 'volume' ? 'is-active' : ''} onClick={() => onMetricChange('volume')}>数量</button>
|
||||
<button type="button" aria-pressed={metric === 'fee'} className={metric === 'fee' ? 'is-active' : ''} onClick={() => onMetricChange('fee')}>金额</button>
|
||||
</div>
|
||||
<strong>{monthLabel(month)}合计:{metric === 'volume' ? `${formatKg(total)} kg` : `¥${formatMoney(total)}`}</strong>
|
||||
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索全部客户" aria-label="搜索全部客户" />
|
||||
</div>
|
||||
<p className="sd-mobile-list__hint">点客户展开近 12 个月 · 合计不随搜索缩减</p>
|
||||
{!sortedCustomers.length ? <p className="sd-mobile-list__state">{search ? '未找到匹配客户' : '该月暂无客户数据'}</p> : <div className="sd-mobile-list__rows">
|
||||
{sortedCustomers.map((customer) => <details className="sd-mobile-list__detail sd-mobile-customer__detail" key={customer.customerName}>
|
||||
<summary><span>{customer.customerName}</span><strong className={customerMetricValue(customer, month) === 0 ? 'is-zero' : ''}>{metric === 'volume' ? `${formatKg(customerMetricValue(customer, month))} kg` : `¥${formatMoney(customerMetricValue(customer, month))}`}</strong></summary>
|
||||
<div className="sd-mobile-customer__trend" aria-label={`${customer.customerName}十二个月趋势`}>
|
||||
{months.map((item) => {
|
||||
const value = customerMetricValue(customer, item);
|
||||
const max = Math.max(...months.map((key) => customerMetricValue(customer, key)), 0);
|
||||
const width = max > 0 ? `${(value / max) * 100}%` : '0%';
|
||||
return <div key={item}><span>{item}</span><i aria-hidden style={{ width }} className={value === 0 ? 'is-zero' : ''} /><strong>{metric === 'volume' ? `${formatKg(value)} kg` : `¥${formatMoney(value)}`}</strong></div>;
|
||||
})}
|
||||
</div>
|
||||
</details>)}
|
||||
</div>}
|
||||
</section>;
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"directory": {
|
||||
"title": "加氢站日报",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "overview",
|
||||
"title": "产品需求说明(PRD)",
|
||||
"type": "markdown",
|
||||
"path": ".spec/requirements-prd.md",
|
||||
"description": "口令 lingniu · 起止查询日期 · KPI钻取 · 各站行内加氢量占比 · 按量降序"
|
||||
},
|
||||
{
|
||||
"id": "app",
|
||||
"title": "加氢站日报",
|
||||
"type": "route",
|
||||
"path": "/prototypes/energy-h2-station-daily",
|
||||
"description": "起止日期+KPI钻取+占比+各站单卡;钻取页可导出xlsx"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+3530
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* @name 加氢站日报
|
||||
* @description 加氢站经营日报 · 能源BI皮 · 口令 lingniu
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import {
|
||||
type AnnotationSourceDocument,
|
||||
type AnnotationViewerOptions,
|
||||
} from '@axhub/annotation';
|
||||
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
|
||||
import { clearHostPrototypeRouteInfo } from '../../common/useHashPage';
|
||||
import { StationDailyAccessGate, isStationDailyAuthed } from './StationDailyAccessGate';
|
||||
import { StationDailyApp } from './StationDailyApp';
|
||||
import annotationSourceDocument from './annotation-source.json';
|
||||
|
||||
function AuthedStationDaily() {
|
||||
const [ok, setOk] = useState(() => isStationDailyAuthed());
|
||||
if (!ok) return <StationDailyAccessGate onOk={() => setOk(true)} />;
|
||||
return <StationDailyApp />;
|
||||
}
|
||||
|
||||
export default function EnergyH2StationDailyEntry() {
|
||||
useEffect(() => {
|
||||
clearHostPrototypeRouteInfo();
|
||||
}, []);
|
||||
|
||||
const annotationOptions = useMemo<AnnotationViewerOptions>(
|
||||
() => ({ title: '加氢站日报' }),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<PrototypeAnnotationHost
|
||||
source={annotationSourceDocument as unknown as AnnotationSourceDocument}
|
||||
options={annotationOptions}
|
||||
>
|
||||
<AuthedStationDaily />
|
||||
</PrototypeAnnotationHost>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
const container = document.getElementById('root');
|
||||
if (container && !container.dataset.energyH2StationDailyMounted) {
|
||||
container.dataset.energyH2StationDailyMounted = '1';
|
||||
createRoot(container).render(<EnergyH2StationDailyEntry />);
|
||||
}
|
||||
}
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { customerMonthTotal, formatMoney, mobileCustomerRows, mobileDailyRows } from './station-mobile-list-model';
|
||||
|
||||
test('日列表倒序且从全量查询读取实际前日值', () => {
|
||||
const rows = [{ date: '2026-08-11', quantityKg: 9, amountYuan: 90, vehicleCount: 1, unitPrice: 10 }];
|
||||
const result = mobileDailyRows(rows, [...rows, { date: '2026-08-10', quantityKg: 7, amountYuan: 70, vehicleCount: 1, unitPrice: 10 }]);
|
||||
assert.equal(result[0].previousKg, 7);
|
||||
});
|
||||
|
||||
test('客户按当前月指标排序,搜索覆盖全部客户且零值保持零', () => {
|
||||
const customers = [
|
||||
{ customerName: '甲运输', months: { '2026-08': 0 } },
|
||||
{ customerName: '乙物流', months: { '2026-08': 12 } },
|
||||
];
|
||||
assert.deepEqual(mobileCustomerRows(customers, '2026-08', '').map((item) => item.customerName), ['乙物流', '甲运输']);
|
||||
assert.equal(customerMonthTotal(customers, '2026-08'), 12);
|
||||
assert.deepEqual(mobileCustomerRows(customers, '2026-08', '甲').map((item) => item.customerName), ['甲运输']);
|
||||
});
|
||||
|
||||
test('日报区分真实前日零和缺失前日,倒序展示不改变源数组', () => {
|
||||
const rows = [
|
||||
{ date: '2026-03-01', quantityKg: 9, amountYuan: 90, vehicleCount: 1, unitPrice: 10 },
|
||||
{ date: '2026-03-02', quantityKg: 0, amountYuan: 0, vehicleCount: 0, unitPrice: 0 },
|
||||
];
|
||||
const result = mobileDailyRows(rows, [...rows, { date: '2026-02-28', quantityKg: 0, amountYuan: 0, vehicleCount: 0, unitPrice: 0 }]);
|
||||
assert.deepEqual(result.map(row => row.date), ['2026-03-02', '2026-03-01']);
|
||||
assert.equal(result[1].previousKg, 0);
|
||||
assert.equal(mobileDailyRows(rows, rows)[1].previousKg, null);
|
||||
assert.equal(rows[0].date, '2026-03-01');
|
||||
});
|
||||
|
||||
test('货币统一保留两位小数,包括真实零', () => {
|
||||
assert.equal(formatMoney(0), '0.00');
|
||||
assert.equal(formatMoney(1234.5), '1,234.50');
|
||||
});
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/** Mobile list derivations are deliberately data-only so the view never invents zeroes. */
|
||||
export interface StationDailyVolumeRow {
|
||||
date: string;
|
||||
quantityKg: number;
|
||||
amountYuan: number;
|
||||
vehicleCount: number;
|
||||
unitPrice: number;
|
||||
}
|
||||
|
||||
export interface StationMonthlyCustomer {
|
||||
customerName: string;
|
||||
months: Record<string, number>;
|
||||
}
|
||||
|
||||
export type CustomerMetric = 'volume' | 'fee';
|
||||
|
||||
export function formatKg(value: number): string {
|
||||
return Number(value || 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
export function formatMoney(value: number): string {
|
||||
return Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
/** Latest first; allRows supplies the true previous-day quantity even outside the seven-day view. */
|
||||
export function mobileDailyRows(rows: StationDailyVolumeRow[], allRows: StationDailyVolumeRow[]) {
|
||||
const quantitiesByDate = new Map(allRows.map((row) => [row.date, row.quantityKg]));
|
||||
return [...rows]
|
||||
.sort((left, right) => right.date.localeCompare(left.date))
|
||||
.map((row) => {
|
||||
const previous = new Date(`${row.date}T00:00:00Z`);
|
||||
previous.setUTCDate(previous.getUTCDate() - 1);
|
||||
const previousDate = previous.toISOString().slice(0, 10);
|
||||
const previousKg = quantitiesByDate.get(previousDate);
|
||||
return { ...row, previousKg: Number.isFinite(previousKg) ? previousKg : null };
|
||||
});
|
||||
}
|
||||
|
||||
export function customerMetricValue(customer: StationMonthlyCustomer, month: string): number {
|
||||
const value = customer.months[month];
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
export function mobileCustomerRows(
|
||||
customers: StationMonthlyCustomer[],
|
||||
month: string,
|
||||
search: string,
|
||||
): StationMonthlyCustomer[] {
|
||||
const keyword = search.trim().toLocaleLowerCase('zh-CN');
|
||||
return customers
|
||||
.filter((customer) => !keyword || customer.customerName.toLocaleLowerCase('zh-CN').includes(keyword))
|
||||
.sort((left, right) => customerMetricValue(right, month) - customerMetricValue(left, month)
|
||||
|| left.customerName.localeCompare(right.customerName, 'zh-CN'));
|
||||
}
|
||||
|
||||
export function customerMonthTotal(customers: StationMonthlyCustomer[], month: string): number {
|
||||
return customers.reduce((total, customer) => total + customerMetricValue(customer, month), 0);
|
||||
}
|
||||
|
||||
export function monthLabel(month: string): string {
|
||||
const matched = /^(\d{4})-(\d{2})$/.exec(month);
|
||||
return matched ? `${matched[1]}年${Number(matched[2])}月` : month;
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/* Pilot is intentionally isolated: the host chooses where to render it. */
|
||||
.sd-mobile-list-pilot { display: none; }
|
||||
.sd-mobile-list__hint { margin: 4px 0 8px; color: #64748b; font-size: 11px; line-height: 1.5; }
|
||||
@media (max-width: 767px) {
|
||||
.sd-mobile-list-pilot { display: block; box-sizing: border-box; width: 100%; color: #24344e; font-family: var(--sd-font, -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif); }
|
||||
.sd-mobile-list__head, .sd-mobile-daily__row { display: grid; grid-template-columns: .82fr .9fr 1.28fr; align-items: center; gap: 7px; }
|
||||
.sd-mobile-list__head { padding: 0 2px 6px; color: #71819a; font-size: 10px; }
|
||||
.sd-mobile-list__head span:not(:first-child), .sd-mobile-daily__row > span:not(:first-child) { text-align: right; }
|
||||
.sd-mobile-list__detail { border-top: 1px solid #edf1f6; }
|
||||
.sd-mobile-list__detail summary { list-style: none; cursor: pointer; min-height: 48px; }
|
||||
.sd-mobile-list__detail summary::-webkit-details-marker { display: none; }
|
||||
.sd-mobile-daily__row > span { display: flex; min-width: 0; align-items: baseline; justify-content: flex-end; gap: 3px; }
|
||||
.sd-mobile-daily__row > span:first-child { justify-content: flex-start; }
|
||||
.sd-mobile-daily__row strong { color: #1e3556; font-size: 13px; font-variant-numeric: tabular-nums; }
|
||||
.sd-mobile-daily__row small { color: #75859c; font-size: 9px; }
|
||||
.sd-mobile-list-pilot .is-zero { color: #94a3b8; }
|
||||
.sd-mobile-list__expanded { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; padding: 0 0 10px; color: #64748b; font-size: 11px; }
|
||||
.sd-mobile-list__expanded span:last-of-type { grid-column: 1 / -1; }
|
||||
.sd-mobile-list__expanded button { grid-column: 1 / -1; min-height: 44px; border: 1px solid #bfdbfe; border-radius: 8px; background: #eff6ff; color: #2563eb; font: inherit; font-weight: 700; }
|
||||
.sd-mobile-list__state { margin: 0; color: #64748b; font-size: 12px; text-align: center; }
|
||||
.sd-mobile-list__state.is-error { color: #b42318; }
|
||||
.sd-mobile-customer__controls { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.sd-mobile-customer__controls select, .sd-mobile-customer__controls input, .sd-mobile-customer__metric button { min-width: 0; min-height: 44px; border: 1px solid #d8e2ef; border-radius: 8px; background: #fff; color: #34445f; font: inherit; font-size: 12px; }
|
||||
.sd-mobile-customer__metric { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; }
|
||||
.sd-mobile-customer__metric button.is-active { border-color: #2563eb; background: #eff6ff; color: #2563eb; font-weight: 700; }
|
||||
.sd-mobile-customer__controls > strong, .sd-mobile-customer__controls input { grid-column: 1 / -1; }
|
||||
.sd-mobile-customer__controls > strong { color: #1e3556; font-size: 12px; }
|
||||
.sd-mobile-customer__controls input { box-sizing: border-box; padding: 0 10px; }
|
||||
.sd-mobile-customer__detail summary { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; }
|
||||
.sd-mobile-customer__detail summary span { display: -webkit-box; overflow: hidden; color: #263650; font-size: 12px; font-weight: 650; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
|
||||
.sd-mobile-customer__detail summary strong { color: #1e3556; font-size: 12px; font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.sd-mobile-customer__trend { display: grid; gap: 4px; padding: 0 0 10px; }
|
||||
.sd-mobile-customer__trend > div { display: grid; grid-template-columns: 50px minmax(0, 1fr) 82px; align-items: center; gap: 6px; color: #71819a; font-size: 10px; }
|
||||
.sd-mobile-customer__trend i { display: block; height: 5px; min-width: 0; border-radius: 99px; background: #60a5fa; }
|
||||
.sd-mobile-customer__trend i.is-zero { width: 0 !important; }
|
||||
.sd-mobile-customer__trend strong { color: #51627b; font-size: 10px; font-variant-numeric: tabular-nums; text-align: right; }
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { customerMonthLabel, customerMonthRange, dateRangeLabel, inclusiveDayCount } from './station-month-range';
|
||||
|
||||
test('客户月度范围保持截至结束日的连续 12 个月,并包含无数据的 5 月', () => {
|
||||
assert.deepEqual(customerMonthRange('2026-08-31'), [
|
||||
'2025-09', '2025-10', '2025-11', '2025-12',
|
||||
'2026-01', '2026-02', '2026-03', '2026-04',
|
||||
'2026-05', '2026-06', '2026-07', '2026-08',
|
||||
]);
|
||||
});
|
||||
|
||||
test('完整年月键不会随截止月份漂移', () => {
|
||||
assert.deepEqual(customerMonthRange('2027-03-01', 3), ['2027-01', '2027-02', '2027-03']);
|
||||
assert.equal(customerMonthLabel('2026-05'), '2026年5月');
|
||||
});
|
||||
|
||||
test('日期区间文案反映闭区间实际天数', () => {
|
||||
assert.equal(inclusiveDayCount('2026-08-01', '2026-08-15'), 15);
|
||||
assert.equal(dateRangeLabel('2026-08-01', '2026-08-15'), '2026-08-01 至 2026-08-15(共 15 天)');
|
||||
assert.equal(inclusiveDayCount('2026-08-15', '2026-08-01'), 0);
|
||||
});
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 客户月度接口固定返回“截至查询结束日的近 12 个月”。
|
||||
* 键保留完整 YYYY-MM,避免跨年时把不同年份的同月合并。
|
||||
*/
|
||||
export function customerMonthRange(asOfYmd: string, count = 12): string[] {
|
||||
const matched = /^(\d{4})-(\d{2})-\d{2}$/.exec(asOfYmd);
|
||||
if (!matched || count < 1) return [];
|
||||
|
||||
const endYear = Number(matched[1]);
|
||||
const endMonth = Number(matched[2]);
|
||||
if (endMonth < 1 || endMonth > 12) return [];
|
||||
|
||||
const months: string[] = [];
|
||||
for (let offset = count - 1; offset >= 0; offset -= 1) {
|
||||
const date = new Date(Date.UTC(endYear, endMonth - 1 - offset, 1));
|
||||
months.push(`${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}`);
|
||||
}
|
||||
return months;
|
||||
}
|
||||
|
||||
export function customerMonthLabel(monthKey: string): string {
|
||||
const matched = /^(\d{4})-(\d{2})$/.exec(monthKey);
|
||||
return matched ? `${matched[1]}年${Number(matched[2])}月` : monthKey;
|
||||
}
|
||||
|
||||
/** 闭区间日数;用于让卡片副标题与实际查询口径保持一致。 */
|
||||
export function inclusiveDayCount(start: string, end: string): number {
|
||||
const startTime = Date.parse(`${start}T00:00:00Z`);
|
||||
const endTime = Date.parse(`${end}T00:00:00Z`);
|
||||
if (!Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime < startTime) return 0;
|
||||
return Math.floor((endTime - startTime) / 86_400_000) + 1;
|
||||
}
|
||||
|
||||
export function dateRangeLabel(start: string, end: string): string {
|
||||
const days = inclusiveDayCount(start, end);
|
||||
return days > 0 ? `${start} 至 ${end}(共 ${days} 天)` : `${start} 至 ${end}`;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
src/vendor/lnbi-8113-exact/resources/design-system/fonts/jetbrains-mono/JetBrainsMono-SemiBold.woff2
Vendored
BIN
Binary file not shown.
+93
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
# JetBrains Mono(OneOS V2 自托管)
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 版本 | 2.304 |
|
||||
| 来源 | https://github.com/JetBrains/JetBrainsMono |
|
||||
| 许可 | SIL Open Font License 1.1(`OFL.txt`) |
|
||||
| 用途 | 金额 / 日期 / 单号 / 车牌等数据等宽数字(DESIGN §2.2) |
|
||||
|
||||
引入:由 `oneos-ds-tokens.css` `@import` 本目录 `jetbrains-mono.css`,**禁止**再绑 Google Fonts CDN。
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* OneOS V2 · JetBrains Mono(自托管)
|
||||
* 版本:2.304 · 许可:SIL OFL 1.1(见同目录 OFL.txt)
|
||||
* 禁止改回 Google Fonts CDN:内网 / Windows 无外网时必须本地 woff2。
|
||||
*/
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
src: url('./JetBrainsMono-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
src: url('./JetBrainsMono-Medium.woff2') format('woff2');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
src: url('./JetBrainsMono-SemiBold.woff2') format('woff2');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
src: url('./JetBrainsMono-Bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
src: url('./JetBrainsMono-ExtraBold.woff2') format('woff2');
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
+6
-1
@@ -17,5 +17,10 @@
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/modules/energy/hydrogen-bi-v2/prototype-source/**"]
|
||||
"exclude": [
|
||||
"src/modules/energy/hydrogen-bi-v2/prototype-source/**",
|
||||
"src/vendor/lnbi-original/**",
|
||||
"src/vendor/lnbi-8113-exact/prototypes/energy-h2-bi-board/index.tsx",
|
||||
"src/vendor/lnbi-8113-exact/prototypes/energy-h2-station-daily/index.tsx"
|
||||
]
|
||||
}
|
||||
|
||||
+23
-3
@@ -1,15 +1,35 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { energyDevMockApi } from './src/modules/energy/hydrogen-bi-v2/dev-mock-api';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
...(env.DEV_MOCK_API === '1' ? [energyDevMockApi()] : []),
|
||||
],
|
||||
server: {
|
||||
allowedHosts: ['biz.u2145933.nyat.app'],
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
configure(proxy) {
|
||||
// A failed upstream BI aggregation must become an HTTP error for
|
||||
// the UI fallback, never terminate the 8115 preview process.
|
||||
proxy.on('error', (_error, _request, response) => {
|
||||
if (response && 'writeHead' in response && !response.headersSent) {
|
||||
response.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
response.end('Upstream API temporarily unavailable');
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user