fix(energy): checkpoint validated drill pagination and read-only preview

Co-authored-by: HiFox Agent <agents-noreply@hifox.com>
This commit is contained in:
kfluous
2026-09-05 15:36:09 +08:00
co-authored by HiFox Agent
parent 6c91a6694a
commit 98efde3f75
20 changed files with 564 additions and 71 deletions
+12
View File
@@ -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
View File
@@ -1,4 +1,5 @@
node_modules
dist
.env
.env.local
.worktrees
+13
View File
@@ -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 的真实数据响应。
+3
View File
@@ -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",
+94 -4
View File
@@ -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() {
@@ -87,6 +90,93 @@ 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,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 页保护上限/,
);
});
});
@@ -15,6 +15,34 @@
.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;
@@ -2,7 +2,7 @@ import { Component, Fragment, useEffect, useMemo, useRef, useState } from "react
import type { ErrorInfo, ReactNode } from "react";
import { ChevronDown, ChevronLeft, Download, Search, SlidersHorizontal, Truck, X } from "lucide-react";
import { MobileListFullscreenButton } from "../../../vendor/lnbi-8113-exact/common/MobileListFullscreenButton";
import { fetchH2BiDrill, fetchH2BiMeta } from "./api";
import { fetchAllH2BiDrill, fetchH2BiDrill, fetchH2BiMeta } from "./api";
import { downloadExcelAoa } from "./prototype-download";
import { bearingLabels } from "./bearing-labels";
import type {
@@ -327,11 +327,17 @@ function nextState(current: DrillState, row: H2BiDrillGroupRow): DrillState {
return current;
}
function useDrill(query: H2BiQuery, state: DrillState, enabled = true) {
function useDrill(
query: H2BiQuery,
state: DrillState,
page: number,
pageSize: number,
enabled = true,
) {
const [data, setData] = useState<H2BiDrillResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(enabled);
const key = JSON.stringify({ ...query, ...state });
const key = JSON.stringify({ ...query, ...state, page, pageSize });
useEffect(() => {
if (!enabled) {
setData(null);
@@ -340,6 +346,7 @@ function useDrill(query: H2BiQuery, state: DrillState, enabled = true) {
return;
}
let alive = true;
const controller = new AbortController();
let finishTimer: ReturnType<typeof setTimeout> | undefined;
const loadingStartedAt = Date.now();
setLoading(true);
@@ -350,8 +357,10 @@ function useDrill(query: H2BiQuery, state: DrillState, enabled = true) {
...state,
groupBy: state.level,
amountScope: state.amountScope,
page: 1,
pageSize: 200,
page,
pageSize,
}, {
signal: controller.signal,
})
.then((result) => alive && setData(result))
.catch((reason: unknown) => {
@@ -369,9 +378,10 @@ function useDrill(query: H2BiQuery, state: DrillState, enabled = true) {
});
return () => {
alive = false;
controller.abort();
if (finishTimer) clearTimeout(finishTimer);
};
}, [enabled, key]);
}, [enabled, key, page, pageSize]);
return { data, error, loading };
}
@@ -707,6 +717,11 @@ function GroupTable({
))}
</ul>
) : "当前层暂无子级数据"}
{expandedData?.page.hasMore ? (
<p className="ehb-modal-hint-text">
{expandedData.page.itemCount ?? 50}
</p>
) : null}
{!expandedLoading && !expandedError ? (
<button type="button" className="ehb-tree-expanded-all" onClick={() => onOpen(row)}>{nextLevel} </button>
) : null}
@@ -764,6 +779,9 @@ export function PrototypeDrillModal({
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
const [expandedRow, setExpandedRow] = useState<H2BiDrillGroupRow | null>(null);
const [meta, setMeta] = useState<H2BiMetaResponse | null>(null);
const [page, setPage] = useState(1);
const [exportingAll, setExportingAll] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
const [fleetCategoryFilter, setFleetCategoryFilter] = useState<
"all" | "own" | "external"
>(() =>
@@ -778,6 +796,7 @@ export function PrototypeDrillModal({
const historyScrollRef = useRef<number[]>([]);
const pendingScrollRef = useRef<number | null>(null);
const tableWrapRef = useRef<HTMLDivElement>(null);
const exportControllerRef = useRef<AbortController | null>(null);
const stateRef = useRef(state);
const forceCloseRef = useRef(false);
const onCloseRef = useRef(onClose);
@@ -864,12 +883,26 @@ export function PrototypeDrillModal({
}),
[fleetCategoryFilter, label, query],
);
const live = useDrill(liveQuery, state);
const live = useDrill(liveQuery, state, page, 100);
const expandedState = useMemo(
() => expandedRow ? nextState(state, expandedRow) : state,
[expandedRow, state],
);
const expandedLive = useDrill(liveQuery, expandedState, Boolean(expandedRow));
const expandedLive = useDrill(liveQuery, expandedState, 1, 50, Boolean(expandedRow));
const drillFilterKey = JSON.stringify({ liveQuery, state });
useEffect(() => {
setPage(1);
// Full exports are tied to the exact filter and drill level that started
// them. Changing either must not leave an old export running in the
// background or present its result as if it belonged to the new view.
if (exportControllerRef.current) {
exportControllerRef.current.abort();
exportControllerRef.current = null;
setExportingAll(false);
}
setExportError(null);
}, [drillFilterKey]);
useEffect(() => () => exportControllerRef.current?.abort(), []);
const data = useMemo(() => {
if (!live.data || !search.trim()) return live.data;
const term = search.trim().toLowerCase();
@@ -893,6 +926,7 @@ export function PrototypeDrillModal({
historyScrollRef.current.push(tableWrapRef.current?.scrollTop ?? 0);
setHistory((items) => [...items, state]);
setState(nextState(state, row));
setPage(1);
setExpandedRow(null);
};
const toggle = (row: H2BiDrillGroupRow) => {
@@ -964,8 +998,7 @@ export function PrototypeDrillModal({
setSearch("");
setState((current) => ({ ...current, amountScope }));
};
const exportCurrent = () => {
if (!data) return;
const exportData = (exported: H2BiDrillResponse, suffix: string) => {
const rows: Array<Array<string | number>> = [
[
"加氢站 / 客户 / 车辆与凭证链路",
@@ -975,7 +1008,7 @@ export function PrototypeDrillModal({
],
];
if (state.level === "record") {
data.records.forEach((record) =>
exported.records.forEach((record) =>
rows.push([
`${String(record.stationName || "未关联站点")} / ${String(record.customerName || "未关联客户")} / ${String(record.plateNo || "无车牌")} / ${String(record.orderNo || record.id || "—")}`,
Number(record.kg ?? 0),
@@ -984,11 +1017,42 @@ export function PrototypeDrillModal({
]),
);
} else {
data.groups.forEach((row) =>
exported.groups.forEach((row) =>
rows.push([row.name, row.kg, row.cost, row.revenue]),
);
}
downloadExcelAoa(rows, `${cleanLabel}_真实账本穿透.xlsx`, "真实账本穿透");
downloadExcelAoa(rows, `${cleanLabel}_${suffix}_真实账本穿透.xlsx`, "真实账本穿透");
};
const exportCurrent = () => {
if (data) exportData(data, `${page}`);
};
const exportAll = async () => {
if (exportingAll) {
exportControllerRef.current?.abort();
return;
}
const controller = new AbortController();
exportControllerRef.current = controller;
setExportingAll(true);
setExportError(null);
try {
const complete = await fetchAllH2BiDrill({
...liveQuery,
...state,
groupBy: state.level,
amountScope: state.amountScope,
}, { signal: controller.signal });
if (!controller.signal.aborted) exportData(complete, "全部");
} catch (reason) {
if (!controller.signal.aborted) {
setExportError(reason instanceof Error ? reason.message : "全量导出失败,未生成文件");
}
} finally {
if (exportControllerRef.current === controller) {
exportControllerRef.current = null;
setExportingAll(false);
}
}
};
const switchRootDimension = (rootDimension: DrillRootDimension) => {
if (kind !== "kpi" || state.rootDimension === rootDimension) return;
@@ -1203,15 +1267,20 @@ export function PrototypeDrillModal({
</button>
))}
</div>
<button type="button" className="ehb-btn ehb-btn--outline ehb-export-btn" onClick={exportCurrent}>
<button type="button" className="ehb-btn ehb-btn--outline ehb-export-btn" onClick={exportCurrent} disabled={!data}>
<Download size={14} aria-hidden />
Excel 穿
</button>
<span className="ehb-modal-hint-text"> </span>
<button type="button" className="ehb-btn ehb-btn--outline ehb-export-btn" onClick={() => void exportAll()} disabled={!data && !exportingAll}>
<Download size={14} aria-hidden />
{exportingAll ? "取消全量导出" : "导出全部"}
</button>
<span className="ehb-modal-hint-text"> 100 </span>
{exportError ? <span className="ehb-modal-hint-text" role="alert">{exportError}</span> : null}
</div>
<label className="ehb-modal-search-input">
<Search size={14} />
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索当前结果" />
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索当前页结果" aria-label="搜索当前页结果" />
</label>
</div>
<div className="ehb-drill-usage-guide" aria-label="明细表操作说明">
@@ -1241,6 +1310,13 @@ export function PrototypeDrillModal({
<div className="ehb-empty"></div>
)}
</div>
{data ? (
<div className="ehb-drill-page-controls" aria-label="明细分页" style={{ display: "flex", justifyContent: "flex-end", alignItems: "center", gap: 8, marginTop: 10 }}>
<span className="ehb-modal-hint-text"> {page} · {data.page.itemCount ?? (state.level === "record" ? data.records.length : data.groups.length)} </span>
<button type="button" className="ehb-btn ehb-btn--outline" disabled={page === 1 || live.loading} onClick={() => setPage((current) => Math.max(1, current - 1))}></button>
<button type="button" className="ehb-btn ehb-btn--outline" disabled={!live.data?.page.hasMore || live.loading} onClick={() => setPage((current) => current + 1)}></button>
</div>
) : null}
</div>
</div>
</div>
@@ -238,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;
+2
View File
@@ -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);
+1
View File
@@ -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);
});
+21
View File
@@ -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');
});
+27
View File
@@ -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;
}
});
+10
View File
@@ -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();
};
+3 -2
View File
@@ -765,7 +765,8 @@ 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([
@@ -864,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 },
};
}
+4 -1
View File
@@ -246,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, 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,
@@ -1463,14 +1463,15 @@ export const EnergyBiBoardApp: React.FC = () => {
// 全局看板时间范围(单站模式不展示:维度不同,由站内查询日期自管)
const timeRangeLabel = '统计时间范围';
const timeRangeText = useMemo(() => {
if (hostView === 'daily') {
// 单站内容由 StationDailyApp 使用 dailyStartDate/dailyEndDate 驱动;不能沿用全局年度 overview 范围。
if (boardScope === 'station' || hostView === 'daily') {
return `${dailyStartDate}${dailyEndDate}`;
}
if (liveOverview?.range?.startDate && liveOverview?.range?.endDate) {
return `${liveOverview.range.startDate}${liveOverview.range.endDate}`;
}
return `${year}-01-01 至 ${year}-12-31`;
}, [hostView, dailyStartDate, dailyEndDate, year, liveOverview]);
}, [boardScope, hostView, dailyStartDate, dailyEndDate, year, liveOverview]);
const mobileView: HostView = boardScope === 'station' ? 'daily' : hostView;
const activeMobileFleet = mobileView === 'daily' ? dailyFleetType : fleetScope;
const mobileFilterCount = mobileView === 'daily'
@@ -1831,7 +1832,7 @@ export const EnergyBiBoardApp: React.FC = () => {
</div>
<div className="ehb-mobile-primary-filters">
<BiYearSelect value={year} onChange={(y) => { setYear(y); clearEntity(); }} />
{boardScope === 'global' ? <BiYearSelect value={year} onChange={(y) => { setYear(y); clearEntity(); }} /> : null}
<button
type="button"
style={ACCESSIBLE_CONTROL_STYLE}
@@ -1844,15 +1845,15 @@ export const EnergyBiBoardApp: React.FC = () => {
<span aria-hidden></span>{boardScope === 'station' ? '总览' : hostView === 'overview' ? '总览' : '日期'}
<ChevronsUpDown size={14} aria-hidden />
</button>
<button
{boardScope === 'global' ? <button
type="button"
style={ACCESSIBLE_CONTROL_STYLE}
className="ehb-mobile-order-chip"
onClick={() => { setVerifyScope(verifyScope === 'all' ? 'verified' : 'all'); clearEntity(); }}
>
{verifyScope === 'all' ? '全量订单' : '仅已核对'}
</button>
<button
</button> : null}
{boardScope === 'global' ? <button
type="button"
style={ACCESSIBLE_CONTROL_STYLE}
className="ehb-mobile-fleet-chip"
@@ -1863,7 +1864,7 @@ export const EnergyBiBoardApp: React.FC = () => {
}}
>
{activeMobileFleet === 'all' ? '全部车辆' : activeMobileFleet === 'own' ? '羚牛车辆' : '外部车辆'}
</button>
</button> : null}
<button
type="button"
style={ACCESSIBLE_CONTROL_STYLE}
@@ -1876,7 +1877,7 @@ export const EnergyBiBoardApp: React.FC = () => {
</button>
</div>
<div className="ehb-mobile-current-range">
{boardScope === 'global' ? '全部站点' : '当前站点'} · {activeMobileFleet === 'all' ? '全部车辆' : activeMobileFleet === 'own' ? '羚牛车辆' : '外部车辆'} · {mobileView === 'daily' ? dailyRangePreset === 'custom' ? '自定义' : dailyRangePreset === '15days' ? '近15天' : dailyRangePreset === 'week' ? '本周' : '本月' : verifyScope === 'all' ? '全量订单' : '仅已核对订单'}
{boardScope === 'global' ? '全部站点' : '当前站点'} · {mobileView === 'daily' ? `${dailyStartDate}${dailyEndDate}` : `${activeMobileFleet === 'all' ? '全部车辆' : activeMobileFleet === 'own' ? '羚牛车辆' : '外部车辆'} · ${verifyScope === 'all' ? '全量订单' : '仅已核对订单'}`}
</div>
{mobileView === 'daily' ? (
<div className="ehb-pill-tabs ehb-mobile-daily-presets"><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-pill-btn ${dailyRangePreset === 'week' ? 'is-active' : ''}`} onClick={() => handleDailyPresetChange('week')}></button><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-pill-btn ${dailyRangePreset === 'month' ? 'is-active' : ''}`} onClick={() => handleDailyPresetChange('month')}></button><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-pill-btn ${dailyRangePreset === '15days' ? 'is-active' : ''}`} onClick={() => handleDailyPresetChange('15days')}>15</button><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-pill-btn ${dailyRangePreset === 'custom' ? 'is-active' : ''}`} onClick={() => { handleDailyPresetChange('custom'); setFiltersOpen(true); }}></button></div>
@@ -1892,7 +1893,7 @@ export const EnergyBiBoardApp: React.FC = () => {
) : (
<>
<div className="ehb-mobile-date-fields"><BiCustomDatePicker label="开始日期" value={dailyStartDate} onChange={(val) => { setDailyStartDate(val); setDailyRangePreset('custom'); }} /><BiCustomDatePicker label="结束日期" value={dailyEndDate} onChange={(val) => { setDailyEndDate(val); setDailyRangePreset('custom'); }} /></div>
<div className="ehb-mobile-filter-field"><span></span><div className="ehb-fleet-segmented"><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-fleet-btn ${dailyFleetType === 'all' ? 'is-active' : ''}`} onClick={() => setDailyFleetType('all')}></button><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-fleet-btn ${dailyFleetType === 'own' ? 'is-active' : ''}`} onClick={() => setDailyFleetType('own')}></button><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-fleet-btn ${dailyFleetType === 'external' ? 'is-active' : ''}`} onClick={() => setDailyFleetType('external')}></button></div></div>
{boardScope === 'global' ? <div className="ehb-mobile-filter-field"><span></span><div className="ehb-fleet-segmented"><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-fleet-btn ${dailyFleetType === 'all' ? 'is-active' : ''}`} onClick={() => setDailyFleetType('all')}></button><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-fleet-btn ${dailyFleetType === 'own' ? 'is-active' : ''}`} onClick={() => setDailyFleetType('own')}></button><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-fleet-btn ${dailyFleetType === 'external' ? 'is-active' : ''}`} onClick={() => setDailyFleetType('external')}></button></div></div> : null}
<button type="button" className="ehb-mobile-refresh" onClick={handleRefreshData} disabled={dailyRefreshing} aria-busy={dailyRefreshing}><RefreshCw size={14} className={dailyRefreshing ? 'is-spinning' : ''} aria-hidden />{dailyRefreshing ? '加载中…' : '刷新数据'}</button>
</>
)}
@@ -3,7 +3,7 @@
* 单站日报明细 · 对齐汇报 Excel 精简表集
* 近7日红涨绿跌 · 月环比同色 · 列表最多10条可展开
*/
import React, { useEffect, useMemo, useState } from 'react';
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';
@@ -12,7 +12,7 @@ import {
type StationCashIntakeDay,
} from '../../common/energy-spot-cash-intake';
import { fetchHydrogenStationBoard } from '../../../../modules/energy/api';
import { fetchH2BiDrill } from '../../../../modules/energy/hydrogen-bi-v2/api';
import { fetchAllH2BiDrillRecords } from '../../../../modules/energy/hydrogen-bi-v2/api';
import type { HydrogenStationBoardResponse } from '../../../../modules/energy/types';
import {
monthTotals,
@@ -20,7 +20,7 @@ import {
} from './data/mockStationDaily';
import { SdCustomerMultiSelect } from './SdCustomerMultiSelect';
import { SdDateRangePicker } from './SdDateRangePicker';
import { customerMonthLabel, customerMonthRange } from './station-month-range';
import { customerMonthLabel, customerMonthRange, dateRangeLabel } from './station-month-range';
const ROW_LIMIT = 10;
@@ -112,34 +112,32 @@ export const StationDailyDetailView: React.FC<{
const [mobileDetailTab, setMobileDetailTab] = useState<'daily' | 'customer' | 'balance' | 'cash'>('daily');
const [mobileMonthKey, setMobileMonthKey] = useState<string>('');
const [liveBoard, setLiveBoard] = useState<HydrogenStationBoardResponse | null>(null);
const [liveRecords, setLiveRecords] = useState<Record<string, unknown>[]>([]);
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);
setLiveError(null);
setLiveLoading(true);
// 新的站点或日期范围不能沿用旧响应;否则月列已变而数值仍属上一查询。
setLiveBoard(null);
setLiveRecords([]);
Promise.all([
fetchHydrogenStationBoard({ startDate: rangeStart, endDate: rangeEnd, stationId: Number(stationId) }),
fetchH2BiDrill({
year: Number(rangeStart.slice(0, 4)), startDate: rangeStart, endDate: rangeEnd,
vehicleScope: 'all', verifyScope: 'all', stationId, groupBy: 'record', page: 1, pageSize: 500,
}),
]).then(([board, drill]) => {
fetchHydrogenStationBoard({ startDate: rangeStart, endDate: rangeEnd, stationId: Number(stationId) }).then((board) => {
if (!active) return;
setLiveBoard(board);
setLiveRecords(drill.records as Record<string, unknown>[]);
setLiveLoading(false);
}).catch((reason: unknown) => {
if (!active) return;
setLiveError(reason instanceof Error ? reason.message : '站点详情加载失败');
setLiveLoading(false);
});
return () => { active = false; };
return () => { active = false; exportController.current?.abort(); };
}, [stationId, rangeStart, rangeEnd, updatedAt]);
const liveStation = liveBoard?.stations.find((station) => String(station.id) === String(stationId));
@@ -234,19 +232,6 @@ export const StationDailyDetailView: React.FC<{
const custVisible = expandCust ? custCells : custCells.slice(0, ROW_LIMIT);
const feeVisible = expandCust ? feeCells : feeCells.slice(0, ROW_LIMIT);
const vehicleRows = useMemo(
() => liveRecords.map((row) => ({
date: String(row.refuelTime ?? '').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.costPrice) || 0,
amountYuan: Number(row.cost) || 0,
})),
[liveRecords],
);
const cashDays: StationCashIntakeDay[] = useMemo(() => {
void cashTick;
return (liveBoard?.selected?.daily ?? []).filter((row) => row.paymentAmount > 0).map((row) => ({
@@ -294,7 +279,27 @@ export const StationDailyDetailView: React.FC<{
const maxKg = Math.max(...volumeRows.map((r) => r.quantityKg), 1);
const hoverRow = hoverDate ? volumeRows.find((r) => r.date === hoverDate) : null;
const handleExport = () => {
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}`],
@@ -335,7 +340,7 @@ export const StationDailyDetailView: React.FC<{
['充值日期', '客户', '付款方式', '金额(元)'],
...cashLines.map((l) => [l.bizDate, l.customerName, SPOT_PAY_METHOD_LABEL[l.payMethod], l.amount]),
[],
['车辆加氢明细(由汇总派生 · 取证)'],
['车辆加氢明细(查询区间全部真实账本记录)', result.records.length],
['日期', '车牌', '客户', '归属', '加氢量(Kg)', '单价', '金额(元)'],
...vehicleRows.map((r) => [
r.date,
@@ -348,6 +353,14 @@ export const StationDailyDetailView: React.FC<{
]),
];
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 = (
@@ -428,12 +441,14 @@ export const StationDailyDetailView: React.FC<{
<RefreshCw size={16} aria-hidden />
</button>
<button type="button" className="sd-btn sd-btn--primary ehb-hide-h5" onClick={handleExport}>
<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>
@@ -454,29 +469,29 @@ export const StationDailyDetailView: React.FC<{
</div>
</div>
<div className="sd-hero-kpi">
<div className="sd-hero-kpi__label"> 10 </div>
<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)}</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__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 className="sd-hero-kpi__sub">{asOf.slice(0, 7)} · </div>
</div>
<div className="sd-hero-kpi">
<div className="sd-hero-kpi__label"> 10 </div>
<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} 天有进账` : '0 天有进账'}
{cashDays.length ? `${cashDays.length} 天有进账 · ${dateRangeLabel(startDate, end)}` : `0 天有进账 · ${dateRangeLabel(startDate, end)}`}
</div>
</div>
</div>
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { customerMonthLabel, customerMonthRange } from './station-month-range';
import { customerMonthLabel, customerMonthRange, dateRangeLabel, inclusiveDayCount } from './station-month-range';
test('客户月度范围保持截至结束日的连续 12 个月,并包含无数据的 5 月', () => {
assert.deepEqual(customerMonthRange('2026-08-31'), [
@@ -14,3 +14,9 @@ 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);
});
@@ -22,3 +22,16 @@ 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}`;
}