diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..44b7b00 --- /dev/null +++ b/.env.local.example @@ -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= diff --git a/.gitignore b/.gitignore index 7071147..fdacec8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules dist .env +.env.local .worktrees diff --git a/docs/local-preview.md b/docs/local-preview.md new file mode 100644 index 0000000..81100ae --- /dev/null +++ b/docs/local-preview.md @@ -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 的真实数据响应。 diff --git a/package.json b/package.json index fb2e3d1..3fe42ff 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,9 @@ "version": "1.1.15", "type": "module", "scripts": { + "dev:local": "concurrently -k -n server,client \"npm run dev:local:server\" \"npm run dev:local:client\"", + "dev:local:server": "node --import tsx src/server/local.ts", + "dev:local:client": "DEV_MOCK_API=0 VITE_DEV_BYPASS_AUTH=1 vite --host 127.0.0.1 --port 8115 --strictPort", "dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"", "dev:server": "tsx watch src/server/index.ts", "dev:client": "vite --port=3000 --host=0.0.0.0", diff --git a/src/modules/energy/hydrogen-bi-v2/api.ts b/src/modules/energy/hydrogen-bi-v2/api.ts index 5aa1620..8328a33 100644 --- a/src/modules/energy/hydrogen-bi-v2/api.ts +++ b/src/modules/energy/hydrogen-bi-v2/api.ts @@ -3,6 +3,9 @@ import type { H2BiDailyResponse, H2BiDailyTreeQuery, H2BiDailyTreeResponse, + H2BiDrillReadOptions, + H2BiFullDrillOptions, + H2BiFullDrillResponse, H2BiDrillQuery, H2BiDrillResponse, H2BiMetaResponse, @@ -20,9 +23,9 @@ function queryString(query: Record) { return params.toString(); } -function request(path: string, query: object = {}) { +function request(path: string, query: object = {}, options?: RequestInit) { const qs = queryString(query as Record); - return fetchJson(`${BASE}/${path}${qs ? `?${qs}` : ''}`); + return fetchJson(`${BASE}/${path}${qs ? `?${qs}` : ''}`, options); } export function fetchH2BiMeta() { @@ -87,6 +90,93 @@ export function fetchH2BiDailyTree(date: string, query: H2BiDailyTreeQuery) { return request('daily-tree', { date, ...query }); } -export function fetchH2BiDrill(query: H2BiDrillQuery) { - return request('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( + '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, + options: H2BiFullDrillOptions = {}, +): Promise { + 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, + options?: H2BiFullDrillOptions, +) { + return fetchAllH2BiDrill({ ...query, groupBy: 'record' }, options); } diff --git a/src/modules/energy/hydrogen-bi-v2/drill-pagination.test.ts b/src/modules/energy/hydrogen-bi-v2/drill-pagination.test.ts new file mode 100644 index 0000000..09fa4a1 --- /dev/null +++ b/src/modules/energy/hydrogen-bi-v2/drill-pagination.test.ts @@ -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 { + 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, + run: () => Promise, +) { + 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 页保护上限/, + ); + }); +}); diff --git a/src/modules/energy/hydrogen-bi-v2/drill-prototype-parity.css b/src/modules/energy/hydrogen-bi-v2/drill-prototype-parity.css index 923f1aa..da4136e 100644 --- a/src/modules/energy/hydrogen-bi-v2/drill-prototype-parity.css +++ b/src/modules/energy/hydrogen-bi-v2/drill-prototype-parity.css @@ -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; diff --git a/src/modules/energy/hydrogen-bi-v2/prototype-real-drills.tsx b/src/modules/energy/hydrogen-bi-v2/prototype-real-drills.tsx index 30b30ca..541863c 100644 --- a/src/modules/energy/hydrogen-bi-v2/prototype-real-drills.tsx +++ b/src/modules/energy/hydrogen-bi-v2/prototype-real-drills.tsx @@ -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(null); const [error, setError] = useState(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 | 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({ ))} ) : "当前层暂无子级数据"} + {expandedData?.page.hasMore ? ( +

+ 此处仅预览前 {expandedData.page.itemCount ?? 50} 条;进入完整明细后可继续翻页。 +

+ ) : null} {!expandedLoading && !expandedError ? ( ) : null} @@ -764,6 +779,9 @@ export function PrototypeDrillModal({ const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false); const [expandedRow, setExpandedRow] = useState(null); const [meta, setMeta] = useState(null); + const [page, setPage] = useState(1); + const [exportingAll, setExportingAll] = useState(false); + const [exportError, setExportError] = useState(null); const [fleetCategoryFilter, setFleetCategoryFilter] = useState< "all" | "own" | "external" >(() => @@ -778,6 +796,7 @@ export function PrototypeDrillModal({ const historyScrollRef = useRef([]); const pendingScrollRef = useRef(null); const tableWrapRef = useRef(null); + const exportControllerRef = useRef(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> = [ [ "加氢站 / 客户 / 车辆与凭证链路", @@ -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({ ))} - - 提示:点击表格行可四级层层展开【加氢站 → 客户 → 车牌 → 单笔订单与核对明细】 + + 当前表按页加载(每页最多 100 条);“导出全部”才会逐页读取完整结果。 + {exportError ? {exportError} : null}
@@ -1241,6 +1310,13 @@ export function PrototypeDrillModal({
当前筛选范围暂无数据
)}
+ {data ? ( +
+ 第 {page} 页 · 本页 {data.page.itemCount ?? (state.level === "record" ? data.records.length : data.groups.length)} 条 + + +
+ ) : null} diff --git a/src/modules/energy/hydrogen-bi-v2/types.ts b/src/modules/energy/hydrogen-bi-v2/types.ts index 0e4c84b..fd13c46 100644 --- a/src/modules/energy/hydrogen-bi-v2/types.ts +++ b/src/modules/energy/hydrogen-bi-v2/types.ts @@ -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; diff --git a/src/server/app.ts b/src/server/app.ts index 786886b..bfb4fb6 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -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); diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index 1293e6c..003180f 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -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); }); diff --git a/src/server/local.ts b/src/server/local.ts new file mode 100644 index 0000000..ebc8630 --- /dev/null +++ b/src/server/local.ts @@ -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'); +}); diff --git a/src/server/read-only-middleware.test.ts b/src/server/read-only-middleware.test.ts new file mode 100644 index 0000000..b5a7295 --- /dev/null +++ b/src/server/read-only-middleware.test.ts @@ -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; + } +}); diff --git a/src/server/read-only-middleware.ts b/src/server/read-only-middleware.ts new file mode 100644 index 0000000..346372f --- /dev/null +++ b/src/server/read-only-middleware.ts @@ -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(); +}; diff --git a/src/server/routes/energy/hydrogen-bi-v2.ts b/src/server/routes/energy/hydrogen-bi-v2.ts index e0f05ae..731b249 100644 --- a/src/server/routes/energy/hydrogen-bi-v2.ts +++ b/src/server/routes/energy/hydrogen-bi-v2.ts @@ -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 }, }; } diff --git a/src/server/routes/energy/routes.test.ts b/src/server/routes/energy/routes.test.ts index 35eb5ef..2e36c7e 100644 --- a/src/server/routes/energy/routes.test.ts +++ b/src/server/routes/energy/routes.test.ts @@ -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, diff --git a/src/vendor/lnbi-8113-exact/prototypes/energy-h2-bi-board/EnergyBiBoardApp.tsx b/src/vendor/lnbi-8113-exact/prototypes/energy-h2-bi-board/EnergyBiBoardApp.tsx index 0408d99..214452e 100644 --- a/src/vendor/lnbi-8113-exact/prototypes/energy-h2-bi-board/EnergyBiBoardApp.tsx +++ b/src/vendor/lnbi-8113-exact/prototypes/energy-h2-bi-board/EnergyBiBoardApp.tsx @@ -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 = () => {
- { setYear(y); clearEntity(); }} /> + {boardScope === 'global' ? { setYear(y); clearEntity(); }} /> : null} - - + : null}
- 当前范围:{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' ? '全量订单' : '仅已核对订单'}`}
{mobileView === 'daily' ? (
@@ -1892,7 +1893,7 @@ export const EnergyBiBoardApp: React.FC = () => { ) : ( <>
{ setDailyStartDate(val); setDailyRangePreset('custom'); }} /> { setDailyEndDate(val); setDailyRangePreset('custom'); }} />
-
车辆范围
+ {boardScope === 'global' ?
车辆范围
: null} )} diff --git a/src/vendor/lnbi-8113-exact/prototypes/energy-h2-station-daily/StationDailyDetailView.tsx b/src/vendor/lnbi-8113-exact/prototypes/energy-h2-station-daily/StationDailyDetailView.tsx index 629e16b..f61e18a 100644 --- a/src/vendor/lnbi-8113-exact/prototypes/energy-h2-station-daily/StationDailyDetailView.tsx +++ b/src/vendor/lnbi-8113-exact/prototypes/energy-h2-station-daily/StationDailyDetailView.tsx @@ -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(''); const [liveBoard, setLiveBoard] = useState(null); - const [liveRecords, setLiveRecords] = useState[]>([]); + const [exporting, setExporting] = useState(false); + const [exportError, setExportError] = useState(null); + const exportController = useRef(null); const [liveError, setLiveError] = useState(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[]); 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<{ 刷新 - + {exporting ? : null} + {exportError ?
导出失败:{exportError}。未生成文件,请缩小日期范围或重试。
: null} {liveError ? (
站点详情加载失败:{liveError}
@@ -454,29 +469,29 @@ export const StationDailyDetailView: React.FC<{
-
近 10 日加氢
+
查询区间加氢
{kg(rangeKg)} Kg
-
¥{money(rangeAmt)}
+
¥{money(rangeAmt)} · {dateRangeLabel(startDate, end)}
-
本月加氢
+
区间内本月加氢
{kg(monthKg)} Kg
-
{asOf.slice(0, 7)}
+
{asOf.slice(0, 7)} · 仅计入查询区间
-
近 10 日现结
+
查询区间现结
{money(cashTotal)}
- {cashDays.length ? `${cashDays.length} 天有进账` : '0 天有进账'} + {cashDays.length ? `${cashDays.length} 天有进账 · ${dateRangeLabel(startDate, end)}` : `0 天有进账 · ${dateRangeLabel(startDate, end)}`}
diff --git a/src/vendor/lnbi-8113-exact/prototypes/energy-h2-station-daily/station-month-range.test.ts b/src/vendor/lnbi-8113-exact/prototypes/energy-h2-station-daily/station-month-range.test.ts index c530a2a..52de3ec 100644 --- a/src/vendor/lnbi-8113-exact/prototypes/energy-h2-station-daily/station-month-range.test.ts +++ b/src/vendor/lnbi-8113-exact/prototypes/energy-h2-station-daily/station-month-range.test.ts @@ -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); +}); diff --git a/src/vendor/lnbi-8113-exact/prototypes/energy-h2-station-daily/station-month-range.ts b/src/vendor/lnbi-8113-exact/prototypes/energy-h2-station-daily/station-month-range.ts index af20d07..2719e07 100644 --- a/src/vendor/lnbi-8113-exact/prototypes/energy-h2-station-daily/station-month-range.ts +++ b/src/vendor/lnbi-8113-exact/prototypes/energy-h2-station-daily/station-month-range.ts @@ -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}`; +}