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
+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;