fix(energy): checkpoint validated drill pagination and read-only preview
Co-authored-by: HiFox Agent <agents-noreply@hifox.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user