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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+10
-9
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
+52
-37
@@ -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>
|
||||
|
||||
+7
-1
@@ -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);
|
||||
});
|
||||
|
||||
+13
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user