fix(energy): checkpoint validated drill pagination and read-only preview
Co-authored-by: HiFox Agent <agents-noreply@hifox.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
# Copy to .env.local; never commit real credentials. Environment values win.
|
||||||
|
HYDROGEN_DB_HOST=
|
||||||
|
HYDROGEN_DB_PORT=3306
|
||||||
|
HYDROGEN_DB_USER=
|
||||||
|
HYDROGEN_DB_PASSWORD=
|
||||||
|
HYDROGEN_DB_NAME=
|
||||||
|
# Optional legacy/electric business database; use a database-level read-only user.
|
||||||
|
DB_HOST=
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=
|
||||||
|
DB_PASSWORD=
|
||||||
|
DB_NAME=
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
.env
|
.env
|
||||||
|
.env.local
|
||||||
.worktrees
|
.worktrees
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# 本地只读预览
|
||||||
|
|
||||||
|
使用 Node.js 22+ 和 npm,在本项目内执行 `npm ci`,避免依赖其他工作目录的 node_modules 软链接。
|
||||||
|
|
||||||
|
将 `.env.local.example` 复制为 `.env.local` 并填写连接配置,或通过进程环境变量提供配置。使用数据库侧只读账号;不要提交真实凭据。
|
||||||
|
|
||||||
|
执行 `npm run dev:local`,浏览器访问 http://127.0.0.1:8115/energy#hydrogen 。前后端固定绑定本机 8115 / 3001;端口占用时退出,不自动换端口。停止终端进程即可停止服务;此命令不配置开机自启。
|
||||||
|
|
||||||
|
本地入口关闭 mock、启用本地免登录,禁用后台建表与定时归档,并拒绝 API 的 POST / PUT / PATCH / DELETE。请勿把免登录预览暴露到公网。氢能查询启用只读 SQL 检查;这不能替代数据库账号的只读权限。
|
||||||
|
|
||||||
|
`npm run dev` / `npm start` 保留原部署方式;设置 `DB_READ_ONLY=1` 时同样不启动后台任务,且 API 禁止写入。生产登录流程需要写请求,因此不要把本地只读模式当作生产部署配置。
|
||||||
|
|
||||||
|
验证:`npm test`、`npm run lint`、`npm run build`;健康检查为 `GET http://127.0.0.1:3001/api/health`。健康检查成功仅代表进程可用,还需检查氢能 meta / overview 的真实数据响应。
|
||||||
@@ -4,6 +4,9 @@
|
|||||||
"version": "1.1.15",
|
"version": "1.1.15",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
|
||||||
"dev:server": "tsx watch src/server/index.ts",
|
"dev:server": "tsx watch src/server/index.ts",
|
||||||
"dev:client": "vite --port=3000 --host=0.0.0.0",
|
"dev:client": "vite --port=3000 --host=0.0.0.0",
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import type {
|
|||||||
H2BiDailyResponse,
|
H2BiDailyResponse,
|
||||||
H2BiDailyTreeQuery,
|
H2BiDailyTreeQuery,
|
||||||
H2BiDailyTreeResponse,
|
H2BiDailyTreeResponse,
|
||||||
|
H2BiDrillReadOptions,
|
||||||
|
H2BiFullDrillOptions,
|
||||||
|
H2BiFullDrillResponse,
|
||||||
H2BiDrillQuery,
|
H2BiDrillQuery,
|
||||||
H2BiDrillResponse,
|
H2BiDrillResponse,
|
||||||
H2BiMetaResponse,
|
H2BiMetaResponse,
|
||||||
@@ -20,9 +23,9 @@ function queryString(query: Record<string, unknown>) {
|
|||||||
return params.toString();
|
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>);
|
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() {
|
export function fetchH2BiMeta() {
|
||||||
@@ -87,6 +90,93 @@ export function fetchH2BiDailyTree(date: string, query: H2BiDailyTreeQuery) {
|
|||||||
return request<H2BiDailyTreeResponse>('daily-tree', { date, ...query });
|
return request<H2BiDailyTreeResponse>('daily-tree', { date, ...query });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchH2BiDrill(query: H2BiDrillQuery) {
|
export async function fetchH2BiDrill(
|
||||||
return request<H2BiDrillResponse>('drill', query);
|
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-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-hint-text { flex: 1 1 280px; min-width: 220px; color: #7b8aa1; }
|
||||||
.ehb-drill-modal--unified .ehb-modal-table-wrap { border-color: #d7e1ed; border-radius: 8px; background: #fff; }
|
.ehb-drill-modal--unified .ehb-modal-table-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 {
|
.ehb-drill-modal--unified .ehb-modal-table th {
|
||||||
height: 44px; padding: 10px 12px; border-bottom-color: #d7e1ed;
|
height: 44px; padding: 10px 12px; border-bottom-color: #d7e1ed;
|
||||||
background: #f0f3f8; color: #52627a;
|
background: #f0f3f8; color: #52627a;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Component, Fragment, useEffect, useMemo, useRef, useState } from "react
|
|||||||
import type { ErrorInfo, ReactNode } from "react";
|
import type { ErrorInfo, ReactNode } from "react";
|
||||||
import { ChevronDown, ChevronLeft, Download, Search, SlidersHorizontal, Truck, X } from "lucide-react";
|
import { ChevronDown, ChevronLeft, Download, Search, SlidersHorizontal, Truck, X } from "lucide-react";
|
||||||
import { MobileListFullscreenButton } from "../../../vendor/lnbi-8113-exact/common/MobileListFullscreenButton";
|
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 { downloadExcelAoa } from "./prototype-download";
|
||||||
import { bearingLabels } from "./bearing-labels";
|
import { bearingLabels } from "./bearing-labels";
|
||||||
import type {
|
import type {
|
||||||
@@ -327,11 +327,17 @@ function nextState(current: DrillState, row: H2BiDrillGroupRow): DrillState {
|
|||||||
return current;
|
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 [data, setData] = useState<H2BiDrillResponse | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(enabled);
|
const [loading, setLoading] = useState(enabled);
|
||||||
const key = JSON.stringify({ ...query, ...state });
|
const key = JSON.stringify({ ...query, ...state, page, pageSize });
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
setData(null);
|
setData(null);
|
||||||
@@ -340,6 +346,7 @@ function useDrill(query: H2BiQuery, state: DrillState, enabled = true) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let alive = true;
|
let alive = true;
|
||||||
|
const controller = new AbortController();
|
||||||
let finishTimer: ReturnType<typeof setTimeout> | undefined;
|
let finishTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
const loadingStartedAt = Date.now();
|
const loadingStartedAt = Date.now();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -350,8 +357,10 @@ function useDrill(query: H2BiQuery, state: DrillState, enabled = true) {
|
|||||||
...state,
|
...state,
|
||||||
groupBy: state.level,
|
groupBy: state.level,
|
||||||
amountScope: state.amountScope,
|
amountScope: state.amountScope,
|
||||||
page: 1,
|
page,
|
||||||
pageSize: 200,
|
pageSize,
|
||||||
|
}, {
|
||||||
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
.then((result) => alive && setData(result))
|
.then((result) => alive && setData(result))
|
||||||
.catch((reason: unknown) => {
|
.catch((reason: unknown) => {
|
||||||
@@ -369,9 +378,10 @@ function useDrill(query: H2BiQuery, state: DrillState, enabled = true) {
|
|||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
|
controller.abort();
|
||||||
if (finishTimer) clearTimeout(finishTimer);
|
if (finishTimer) clearTimeout(finishTimer);
|
||||||
};
|
};
|
||||||
}, [enabled, key]);
|
}, [enabled, key, page, pageSize]);
|
||||||
return { data, error, loading };
|
return { data, error, loading };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -707,6 +717,11 @@ function GroupTable({
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : "当前层暂无子级数据"}
|
) : "当前层暂无子级数据"}
|
||||||
|
{expandedData?.page.hasMore ? (
|
||||||
|
<p className="ehb-modal-hint-text">
|
||||||
|
此处仅预览前 {expandedData.page.itemCount ?? 50} 条;进入完整明细后可继续翻页。
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{!expandedLoading && !expandedError ? (
|
{!expandedLoading && !expandedError ? (
|
||||||
<button type="button" className="ehb-tree-expanded-all" onClick={() => onOpen(row)}>查看全部{nextLevel} ›</button>
|
<button type="button" className="ehb-tree-expanded-all" onClick={() => onOpen(row)}>查看全部{nextLevel} ›</button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -764,6 +779,9 @@ export function PrototypeDrillModal({
|
|||||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||||||
const [expandedRow, setExpandedRow] = useState<H2BiDrillGroupRow | null>(null);
|
const [expandedRow, setExpandedRow] = useState<H2BiDrillGroupRow | null>(null);
|
||||||
const [meta, setMeta] = useState<H2BiMetaResponse | 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<
|
const [fleetCategoryFilter, setFleetCategoryFilter] = useState<
|
||||||
"all" | "own" | "external"
|
"all" | "own" | "external"
|
||||||
>(() =>
|
>(() =>
|
||||||
@@ -778,6 +796,7 @@ export function PrototypeDrillModal({
|
|||||||
const historyScrollRef = useRef<number[]>([]);
|
const historyScrollRef = useRef<number[]>([]);
|
||||||
const pendingScrollRef = useRef<number | null>(null);
|
const pendingScrollRef = useRef<number | null>(null);
|
||||||
const tableWrapRef = useRef<HTMLDivElement>(null);
|
const tableWrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const exportControllerRef = useRef<AbortController | null>(null);
|
||||||
const stateRef = useRef(state);
|
const stateRef = useRef(state);
|
||||||
const forceCloseRef = useRef(false);
|
const forceCloseRef = useRef(false);
|
||||||
const onCloseRef = useRef(onClose);
|
const onCloseRef = useRef(onClose);
|
||||||
@@ -864,12 +883,26 @@ export function PrototypeDrillModal({
|
|||||||
}),
|
}),
|
||||||
[fleetCategoryFilter, label, query],
|
[fleetCategoryFilter, label, query],
|
||||||
);
|
);
|
||||||
const live = useDrill(liveQuery, state);
|
const live = useDrill(liveQuery, state, page, 100);
|
||||||
const expandedState = useMemo(
|
const expandedState = useMemo(
|
||||||
() => expandedRow ? nextState(state, expandedRow) : state,
|
() => expandedRow ? nextState(state, expandedRow) : 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(() => {
|
const data = useMemo(() => {
|
||||||
if (!live.data || !search.trim()) return live.data;
|
if (!live.data || !search.trim()) return live.data;
|
||||||
const term = search.trim().toLowerCase();
|
const term = search.trim().toLowerCase();
|
||||||
@@ -893,6 +926,7 @@ export function PrototypeDrillModal({
|
|||||||
historyScrollRef.current.push(tableWrapRef.current?.scrollTop ?? 0);
|
historyScrollRef.current.push(tableWrapRef.current?.scrollTop ?? 0);
|
||||||
setHistory((items) => [...items, state]);
|
setHistory((items) => [...items, state]);
|
||||||
setState(nextState(state, row));
|
setState(nextState(state, row));
|
||||||
|
setPage(1);
|
||||||
setExpandedRow(null);
|
setExpandedRow(null);
|
||||||
};
|
};
|
||||||
const toggle = (row: H2BiDrillGroupRow) => {
|
const toggle = (row: H2BiDrillGroupRow) => {
|
||||||
@@ -964,8 +998,7 @@ export function PrototypeDrillModal({
|
|||||||
setSearch("");
|
setSearch("");
|
||||||
setState((current) => ({ ...current, amountScope }));
|
setState((current) => ({ ...current, amountScope }));
|
||||||
};
|
};
|
||||||
const exportCurrent = () => {
|
const exportData = (exported: H2BiDrillResponse, suffix: string) => {
|
||||||
if (!data) return;
|
|
||||||
const rows: Array<Array<string | number>> = [
|
const rows: Array<Array<string | number>> = [
|
||||||
[
|
[
|
||||||
"加氢站 / 客户 / 车辆与凭证链路",
|
"加氢站 / 客户 / 车辆与凭证链路",
|
||||||
@@ -975,7 +1008,7 @@ export function PrototypeDrillModal({
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
if (state.level === "record") {
|
if (state.level === "record") {
|
||||||
data.records.forEach((record) =>
|
exported.records.forEach((record) =>
|
||||||
rows.push([
|
rows.push([
|
||||||
`${String(record.stationName || "未关联站点")} / ${String(record.customerName || "未关联客户")} / ${String(record.plateNo || "无车牌")} / ${String(record.orderNo || record.id || "—")}`,
|
`${String(record.stationName || "未关联站点")} / ${String(record.customerName || "未关联客户")} / ${String(record.plateNo || "无车牌")} / ${String(record.orderNo || record.id || "—")}`,
|
||||||
Number(record.kg ?? 0),
|
Number(record.kg ?? 0),
|
||||||
@@ -984,11 +1017,42 @@ export function PrototypeDrillModal({
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
data.groups.forEach((row) =>
|
exported.groups.forEach((row) =>
|
||||||
rows.push([row.name, row.kg, row.cost, row.revenue]),
|
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) => {
|
const switchRootDimension = (rootDimension: DrillRootDimension) => {
|
||||||
if (kind !== "kpi" || state.rootDimension === rootDimension) return;
|
if (kind !== "kpi" || state.rootDimension === rootDimension) return;
|
||||||
@@ -1203,15 +1267,20 @@ export function PrototypeDrillModal({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 />
|
<Download size={14} aria-hidden />
|
||||||
导出 Excel 穿透账单
|
导出当前页
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<label className="ehb-modal-search-input">
|
<label className="ehb-modal-search-input">
|
||||||
<Search size={14} />
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="ehb-drill-usage-guide" aria-label="明细表操作说明">
|
<div className="ehb-drill-usage-guide" aria-label="明细表操作说明">
|
||||||
@@ -1241,6 +1310,13 @@ export function PrototypeDrillModal({
|
|||||||
<div className="ehb-empty">当前筛选范围暂无数据</div>
|
<div className="ehb-empty">当前筛选范围暂无数据</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -238,10 +238,37 @@ export interface H2BiDrillResponse {
|
|||||||
page: {
|
page: {
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
|
/** Number of rows returned on this page (groups or records). */
|
||||||
|
itemCount?: number;
|
||||||
hasMore: boolean;
|
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 {
|
export interface H2BiDailyTreeCustomer {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { serveStatic } from '@hono/node-server/serve-static';
|
import { serveStatic } from '@hono/node-server/serve-static';
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
|
import { readOnlyMiddleware } from './read-only-middleware.js';
|
||||||
import { cors } from 'hono/cors';
|
import { cors } from 'hono/cors';
|
||||||
import authRouter from './auth/login.js';
|
import authRouter from './auth/login.js';
|
||||||
import { authMiddleware } from './auth/middleware.js';
|
import { authMiddleware } from './auth/middleware.js';
|
||||||
@@ -20,6 +21,7 @@ export function createApp(): Hono {
|
|||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
app.use('/api/*', cors());
|
app.use('/api/*', cors());
|
||||||
|
app.use('/api/*', readOnlyMiddleware);
|
||||||
|
|
||||||
// 登录接口公开,其余 API 统一经过认证与数据权限检查。
|
// 登录接口公开,其余 API 统一经过认证与数据权限检查。
|
||||||
app.route('/api/auth', authRouter);
|
app.route('/api/auth', authRouter);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { startMileageBackgroundJobs } from './routes/mileage/index.js';
|
|||||||
|
|
||||||
/** 启动只应在服务进程中运行的数据库准备和定时任务。 */
|
/** 启动只应在服务进程中运行的数据库准备和定时任务。 */
|
||||||
export function startBackgroundServices(): void {
|
export function startBackgroundServices(): void {
|
||||||
|
if (process.env.DB_READ_ONLY === '1') return;
|
||||||
ensureSchedulingTables().catch((error) => {
|
ensureSchedulingTables().catch((error) => {
|
||||||
console.error('scheduling bootstrap error:', 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"
|
: groupBy === "date"
|
||||||
? `DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')`
|
? `DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')`
|
||||||
: "COALESCE(NULLIF(b.license_plate, ''), '无车牌')";
|
: "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 =
|
const groupHaving =
|
||||||
groupBy === "station" ? "HAVING SUM(COALESCE(b.amount_kg, 0)) > 0" : "";
|
groupBy === "station" ? "HAVING SUM(COALESCE(b.amount_kg, 0)) > 0" : "";
|
||||||
const [summaryRows, groupRows, recordRows] = await Promise.all([
|
const [summaryRows, groupRows, recordRows] = await Promise.all([
|
||||||
@@ -864,7 +865,7 @@ async function drill(
|
|||||||
cost: number(row.cost),
|
cost: number(row.cost),
|
||||||
revenue: number(row.revenue),
|
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);
|
} as unknown as HydrogenBiV2Dependencies);
|
||||||
|
|
||||||
const response = await v2App.request(
|
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);
|
assert.equal(response.status, 200);
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
assert.equal(payload.amountScope, "customer");
|
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(payload.summary.revenue - payload.summary.cost, 60);
|
||||||
assert.equal(calls.length, 2);
|
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) {
|
for (const call of calls) {
|
||||||
assert.match(
|
assert.match(
|
||||||
call.sql,
|
call.sql,
|
||||||
|
|||||||
+10
-9
@@ -1463,14 +1463,15 @@ export const EnergyBiBoardApp: React.FC = () => {
|
|||||||
// 全局看板时间范围(单站模式不展示:维度不同,由站内查询日期自管)
|
// 全局看板时间范围(单站模式不展示:维度不同,由站内查询日期自管)
|
||||||
const timeRangeLabel = '统计时间范围';
|
const timeRangeLabel = '统计时间范围';
|
||||||
const timeRangeText = useMemo(() => {
|
const timeRangeText = useMemo(() => {
|
||||||
if (hostView === 'daily') {
|
// 单站内容由 StationDailyApp 使用 dailyStartDate/dailyEndDate 驱动;不能沿用全局年度 overview 范围。
|
||||||
|
if (boardScope === 'station' || hostView === 'daily') {
|
||||||
return `${dailyStartDate} 至 ${dailyEndDate}`;
|
return `${dailyStartDate} 至 ${dailyEndDate}`;
|
||||||
}
|
}
|
||||||
if (liveOverview?.range?.startDate && liveOverview?.range?.endDate) {
|
if (liveOverview?.range?.startDate && liveOverview?.range?.endDate) {
|
||||||
return `${liveOverview.range.startDate} 至 ${liveOverview.range.endDate}`;
|
return `${liveOverview.range.startDate} 至 ${liveOverview.range.endDate}`;
|
||||||
}
|
}
|
||||||
return `${year}-01-01 至 ${year}-12-31`;
|
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 mobileView: HostView = boardScope === 'station' ? 'daily' : hostView;
|
||||||
const activeMobileFleet = mobileView === 'daily' ? dailyFleetType : fleetScope;
|
const activeMobileFleet = mobileView === 'daily' ? dailyFleetType : fleetScope;
|
||||||
const mobileFilterCount = mobileView === 'daily'
|
const mobileFilterCount = mobileView === 'daily'
|
||||||
@@ -1831,7 +1832,7 @@ export const EnergyBiBoardApp: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ehb-mobile-primary-filters">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
style={ACCESSIBLE_CONTROL_STYLE}
|
style={ACCESSIBLE_CONTROL_STYLE}
|
||||||
@@ -1844,15 +1845,15 @@ export const EnergyBiBoardApp: React.FC = () => {
|
|||||||
<span aria-hidden>●</span>{boardScope === 'station' ? '总览' : hostView === 'overview' ? '总览' : '日期'}
|
<span aria-hidden>●</span>{boardScope === 'station' ? '总览' : hostView === 'overview' ? '总览' : '日期'}
|
||||||
<ChevronsUpDown size={14} aria-hidden />
|
<ChevronsUpDown size={14} aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
<button
|
{boardScope === 'global' ? <button
|
||||||
type="button"
|
type="button"
|
||||||
style={ACCESSIBLE_CONTROL_STYLE}
|
style={ACCESSIBLE_CONTROL_STYLE}
|
||||||
className="ehb-mobile-order-chip"
|
className="ehb-mobile-order-chip"
|
||||||
onClick={() => { setVerifyScope(verifyScope === 'all' ? 'verified' : 'all'); clearEntity(); }}
|
onClick={() => { setVerifyScope(verifyScope === 'all' ? 'verified' : 'all'); clearEntity(); }}
|
||||||
>
|
>
|
||||||
{verifyScope === 'all' ? '全量订单' : '仅已核对'}
|
{verifyScope === 'all' ? '全量订单' : '仅已核对'}
|
||||||
</button>
|
</button> : null}
|
||||||
<button
|
{boardScope === 'global' ? <button
|
||||||
type="button"
|
type="button"
|
||||||
style={ACCESSIBLE_CONTROL_STYLE}
|
style={ACCESSIBLE_CONTROL_STYLE}
|
||||||
className="ehb-mobile-fleet-chip"
|
className="ehb-mobile-fleet-chip"
|
||||||
@@ -1863,7 +1864,7 @@ export const EnergyBiBoardApp: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{activeMobileFleet === 'all' ? '全部车辆' : activeMobileFleet === 'own' ? '羚牛车辆' : '外部车辆'}
|
{activeMobileFleet === 'all' ? '全部车辆' : activeMobileFleet === 'own' ? '羚牛车辆' : '外部车辆'}
|
||||||
</button>
|
</button> : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
style={ACCESSIBLE_CONTROL_STYLE}
|
style={ACCESSIBLE_CONTROL_STYLE}
|
||||||
@@ -1876,7 +1877,7 @@ export const EnergyBiBoardApp: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="ehb-mobile-current-range">
|
<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>
|
</div>
|
||||||
{mobileView === 'daily' ? (
|
{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>
|
<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-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>
|
<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 精简表集
|
* 单站日报明细 · 对齐汇报 Excel 精简表集
|
||||||
* 近7日红涨绿跌 · 月环比同色 · 列表最多10条可展开
|
* 近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 { ArrowLeft, ChevronDown, ChevronUp, Download, RefreshCw } from 'lucide-react';
|
||||||
import { downloadExcelAoa } from '../../common/download-xls';
|
import { downloadExcelAoa } from '../../common/download-xls';
|
||||||
import { MobileListFullscreenButton } from '../../common/MobileListFullscreenButton';
|
import { MobileListFullscreenButton } from '../../common/MobileListFullscreenButton';
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
type StationCashIntakeDay,
|
type StationCashIntakeDay,
|
||||||
} from '../../common/energy-spot-cash-intake';
|
} from '../../common/energy-spot-cash-intake';
|
||||||
import { fetchHydrogenStationBoard } from '../../../../modules/energy/api';
|
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 type { HydrogenStationBoardResponse } from '../../../../modules/energy/types';
|
||||||
import {
|
import {
|
||||||
monthTotals,
|
monthTotals,
|
||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
} from './data/mockStationDaily';
|
} from './data/mockStationDaily';
|
||||||
import { SdCustomerMultiSelect } from './SdCustomerMultiSelect';
|
import { SdCustomerMultiSelect } from './SdCustomerMultiSelect';
|
||||||
import { SdDateRangePicker } from './SdDateRangePicker';
|
import { SdDateRangePicker } from './SdDateRangePicker';
|
||||||
import { customerMonthLabel, customerMonthRange } from './station-month-range';
|
import { customerMonthLabel, customerMonthRange, dateRangeLabel } from './station-month-range';
|
||||||
|
|
||||||
const ROW_LIMIT = 10;
|
const ROW_LIMIT = 10;
|
||||||
|
|
||||||
@@ -112,34 +112,32 @@ export const StationDailyDetailView: React.FC<{
|
|||||||
const [mobileDetailTab, setMobileDetailTab] = useState<'daily' | 'customer' | 'balance' | 'cash'>('daily');
|
const [mobileDetailTab, setMobileDetailTab] = useState<'daily' | 'customer' | 'balance' | 'cash'>('daily');
|
||||||
const [mobileMonthKey, setMobileMonthKey] = useState<string>('');
|
const [mobileMonthKey, setMobileMonthKey] = useState<string>('');
|
||||||
const [liveBoard, setLiveBoard] = useState<HydrogenStationBoardResponse | null>(null);
|
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 [liveError, setLiveError] = useState<string | null>(null);
|
||||||
const [liveLoading, setLiveLoading] = useState(true);
|
const [liveLoading, setLiveLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
|
exportController.current?.abort();
|
||||||
|
exportController.current = null;
|
||||||
|
setExporting(false);
|
||||||
|
setExportError(null);
|
||||||
setLiveError(null);
|
setLiveError(null);
|
||||||
setLiveLoading(true);
|
setLiveLoading(true);
|
||||||
// 新的站点或日期范围不能沿用旧响应;否则月列已变而数值仍属上一查询。
|
// 新的站点或日期范围不能沿用旧响应;否则月列已变而数值仍属上一查询。
|
||||||
setLiveBoard(null);
|
setLiveBoard(null);
|
||||||
setLiveRecords([]);
|
fetchHydrogenStationBoard({ startDate: rangeStart, endDate: rangeEnd, stationId: Number(stationId) }).then((board) => {
|
||||||
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]) => {
|
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setLiveBoard(board);
|
setLiveBoard(board);
|
||||||
setLiveRecords(drill.records as Record<string, unknown>[]);
|
|
||||||
setLiveLoading(false);
|
setLiveLoading(false);
|
||||||
}).catch((reason: unknown) => {
|
}).catch((reason: unknown) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setLiveError(reason instanceof Error ? reason.message : '站点详情加载失败');
|
setLiveError(reason instanceof Error ? reason.message : '站点详情加载失败');
|
||||||
setLiveLoading(false);
|
setLiveLoading(false);
|
||||||
});
|
});
|
||||||
return () => { active = false; };
|
return () => { active = false; exportController.current?.abort(); };
|
||||||
}, [stationId, rangeStart, rangeEnd, updatedAt]);
|
}, [stationId, rangeStart, rangeEnd, updatedAt]);
|
||||||
|
|
||||||
const liveStation = liveBoard?.stations.find((station) => String(station.id) === String(stationId));
|
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 custVisible = expandCust ? custCells : custCells.slice(0, ROW_LIMIT);
|
||||||
const feeVisible = expandCust ? feeCells : feeCells.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(() => {
|
const cashDays: StationCashIntakeDay[] = useMemo(() => {
|
||||||
void cashTick;
|
void cashTick;
|
||||||
return (liveBoard?.selected?.daily ?? []).filter((row) => row.paymentAmount > 0).map((row) => ({
|
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 maxKg = Math.max(...volumeRows.map((r) => r.quantityKg), 1);
|
||||||
const hoverRow = hoverDate ? volumeRows.find((r) => r.date === hoverDate) : null;
|
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 monthHeads = customerMonthKeys.map(customerMonthLabel);
|
||||||
const aoa: (string | number)[][] = [
|
const aoa: (string | number)[][] = [
|
||||||
[`${stationName} · 站日报(查询日期 ${asOf})`],
|
[`${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]),
|
...cashLines.map((l) => [l.bizDate, l.customerName, SPOT_PAY_METHOD_LABEL[l.payMethod], l.amount]),
|
||||||
[],
|
[],
|
||||||
['车辆加氢明细(由汇总派生 · 取证)'],
|
['车辆加氢明细(查询区间全部真实账本记录)', result.records.length],
|
||||||
['日期', '车牌', '客户', '归属', '加氢量(Kg)', '单价', '金额(元)'],
|
['日期', '车牌', '客户', '归属', '加氢量(Kg)', '单价', '金额(元)'],
|
||||||
...vehicleRows.map((r) => [
|
...vehicleRows.map((r) => [
|
||||||
r.date,
|
r.date,
|
||||||
@@ -348,6 +353,14 @@ export const StationDailyDetailView: React.FC<{
|
|||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
downloadExcelAoa(aoa, `站日报取证_${stationName}_查询${asOf}.xlsx`, '站日报取证');
|
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 = (
|
const renderMonthCells = (
|
||||||
@@ -428,12 +441,14 @@ export const StationDailyDetailView: React.FC<{
|
|||||||
<RefreshCw size={16} aria-hidden />
|
<RefreshCw size={16} aria-hidden />
|
||||||
刷新
|
刷新
|
||||||
</button>
|
</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 />
|
<Download size={15} aria-hidden />
|
||||||
导出取证
|
{exporting ? '正在读取全部记录…' : '导出取证'}
|
||||||
</button>
|
</button>
|
||||||
|
{exporting ? <button type="button" className="sd-btn sd-btn--ghost" onClick={() => exportController.current?.abort()}>取消导出</button> : null}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
{exportError ? <div role="alert" className="ehb-empty">导出失败:{exportError}。未生成文件,请缩小日期范围或重试。</div> : null}
|
||||||
|
|
||||||
{liveError ? (
|
{liveError ? (
|
||||||
<div className="ehb-empty" role="alert">站点详情加载失败:{liveError}</div>
|
<div className="ehb-empty" role="alert">站点详情加载失败:{liveError}</div>
|
||||||
@@ -454,29 +469,29 @@ export const StationDailyDetailView: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="sd-hero-kpi">
|
<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">
|
<div className="sd-hero-kpi__value">
|
||||||
{kg(rangeKg)}
|
{kg(rangeKg)}
|
||||||
<span className="sd-unit">Kg</span>
|
<span className="sd-unit">Kg</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="sd-hero-kpi__sub">¥{money(rangeAmt)}</div>
|
<div className="sd-hero-kpi__sub">¥{money(rangeAmt)} · {dateRangeLabel(startDate, end)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="sd-hero-kpi">
|
<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">
|
<div className="sd-hero-kpi__value">
|
||||||
{kg(monthKg)}
|
{kg(monthKg)}
|
||||||
<span className="sd-unit">Kg</span>
|
<span className="sd-unit">Kg</span>
|
||||||
</div>
|
</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>
|
||||||
<div className="sd-hero-kpi">
|
<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">
|
<div className="sd-hero-kpi__value">
|
||||||
{money(cashTotal)}
|
{money(cashTotal)}
|
||||||
<span className="sd-unit">元</span>
|
<span className="sd-unit">元</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="sd-hero-kpi__sub">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+7
-1
@@ -1,6 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { customerMonthLabel, customerMonthRange } from './station-month-range';
|
import { customerMonthLabel, customerMonthRange, dateRangeLabel, inclusiveDayCount } from './station-month-range';
|
||||||
|
|
||||||
test('客户月度范围保持截至结束日的连续 12 个月,并包含无数据的 5 月', () => {
|
test('客户月度范围保持截至结束日的连续 12 个月,并包含无数据的 5 月', () => {
|
||||||
assert.deepEqual(customerMonthRange('2026-08-31'), [
|
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.deepEqual(customerMonthRange('2027-03-01', 3), ['2027-01', '2027-02', '2027-03']);
|
||||||
assert.equal(customerMonthLabel('2026-05'), '2026年5月');
|
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);
|
const matched = /^(\d{4})-(\d{2})$/.exec(monthKey);
|
||||||
return matched ? `${matched[1]}年${Number(matched[2])}月` : 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