fix(energy): checkpoint validated drill pagination and read-only preview

Co-authored-by: HiFox Agent <agents-noreply@hifox.com>
This commit is contained in:
kfluous
2026-09-05 15:36:09 +08:00
co-authored by HiFox Agent
parent 6c91a6694a
commit 98efde3f75
20 changed files with 564 additions and 71 deletions
@@ -1463,14 +1463,15 @@ export const EnergyBiBoardApp: React.FC = () => {
// 全局看板时间范围(单站模式不展示:维度不同,由站内查询日期自管)
const timeRangeLabel = '统计时间范围';
const timeRangeText = useMemo(() => {
if (hostView === 'daily') {
// 单站内容由 StationDailyApp 使用 dailyStartDate/dailyEndDate 驱动;不能沿用全局年度 overview 范围。
if (boardScope === 'station' || hostView === 'daily') {
return `${dailyStartDate} 至 ${dailyEndDate}`;
}
if (liveOverview?.range?.startDate && liveOverview?.range?.endDate) {
return `${liveOverview.range.startDate} 至 ${liveOverview.range.endDate}`;
}
return `${year}-01-01 至 ${year}-12-31`;
}, [hostView, dailyStartDate, dailyEndDate, year, liveOverview]);
}, [boardScope, hostView, dailyStartDate, dailyEndDate, year, liveOverview]);
const mobileView: HostView = boardScope === 'station' ? 'daily' : hostView;
const activeMobileFleet = mobileView === 'daily' ? dailyFleetType : fleetScope;
const mobileFilterCount = mobileView === 'daily'
@@ -1831,7 +1832,7 @@ export const EnergyBiBoardApp: React.FC = () => {
</div>
<div className="ehb-mobile-primary-filters">
<BiYearSelect value={year} onChange={(y) => { setYear(y); clearEntity(); }} />
{boardScope === 'global' ? <BiYearSelect value={year} onChange={(y) => { setYear(y); clearEntity(); }} /> : null}
<button
type="button"
style={ACCESSIBLE_CONTROL_STYLE}
@@ -1844,15 +1845,15 @@ export const EnergyBiBoardApp: React.FC = () => {
<span aria-hidden>●</span>{boardScope === 'station' ? '总览' : hostView === 'overview' ? '总览' : '日期'}
<ChevronsUpDown size={14} aria-hidden />
</button>
<button
{boardScope === 'global' ? <button
type="button"
style={ACCESSIBLE_CONTROL_STYLE}
className="ehb-mobile-order-chip"
onClick={() => { setVerifyScope(verifyScope === 'all' ? 'verified' : 'all'); clearEntity(); }}
>
{verifyScope === 'all' ? '全量订单' : '仅已核对'}
</button>
<button
</button> : null}
{boardScope === 'global' ? <button
type="button"
style={ACCESSIBLE_CONTROL_STYLE}
className="ehb-mobile-fleet-chip"
@@ -1863,7 +1864,7 @@ export const EnergyBiBoardApp: React.FC = () => {
}}
>
{activeMobileFleet === 'all' ? '全部车辆' : activeMobileFleet === 'own' ? '羚牛车辆' : '外部车辆'}
</button>
</button> : null}
<button
type="button"
style={ACCESSIBLE_CONTROL_STYLE}
@@ -1876,7 +1877,7 @@ export const EnergyBiBoardApp: React.FC = () => {
</button>
</div>
<div className="ehb-mobile-current-range">
当前范围:{boardScope === 'global' ? '全部站点' : '当前站点'} · {activeMobileFleet === 'all' ? '全部车辆' : activeMobileFleet === 'own' ? '羚牛车辆' : '外部车辆'} · {mobileView === 'daily' ? dailyRangePreset === 'custom' ? '自定义' : dailyRangePreset === '15days' ? '近15天' : dailyRangePreset === 'week' ? '本周' : '本月' : verifyScope === 'all' ? '全量订单' : '仅已核对订单'}
当前范围:{boardScope === 'global' ? '全部站点' : '当前站点'} · {mobileView === 'daily' ? `${dailyStartDate} 至 ${dailyEndDate}` : `${activeMobileFleet === 'all' ? '全部车辆' : activeMobileFleet === 'own' ? '羚牛车辆' : '外部车辆'} · ${verifyScope === 'all' ? '全量订单' : '仅已核对订单'}`}
</div>
{mobileView === 'daily' ? (
<div className="ehb-pill-tabs ehb-mobile-daily-presets"><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-pill-btn ${dailyRangePreset === 'week' ? 'is-active' : ''}`} onClick={() => handleDailyPresetChange('week')}>本周</button><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-pill-btn ${dailyRangePreset === 'month' ? 'is-active' : ''}`} onClick={() => handleDailyPresetChange('month')}>本月</button><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-pill-btn ${dailyRangePreset === '15days' ? 'is-active' : ''}`} onClick={() => handleDailyPresetChange('15days')}>近15天</button><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-pill-btn ${dailyRangePreset === 'custom' ? 'is-active' : ''}`} onClick={() => { handleDailyPresetChange('custom'); setFiltersOpen(true); }}>自定义</button></div>
@@ -1892,7 +1893,7 @@ export const EnergyBiBoardApp: React.FC = () => {
) : (
<>
<div className="ehb-mobile-date-fields"><BiCustomDatePicker label="开始日期" value={dailyStartDate} onChange={(val) => { setDailyStartDate(val); setDailyRangePreset('custom'); }} /><BiCustomDatePicker label="结束日期" value={dailyEndDate} onChange={(val) => { setDailyEndDate(val); setDailyRangePreset('custom'); }} /></div>
<div className="ehb-mobile-filter-field"><span>车辆范围</span><div className="ehb-fleet-segmented"><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-fleet-btn ${dailyFleetType === 'all' ? 'is-active' : ''}`} onClick={() => setDailyFleetType('all')}>全部车辆</button><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-fleet-btn ${dailyFleetType === 'own' ? 'is-active' : ''}`} onClick={() => setDailyFleetType('own')}>羚牛车辆</button><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-fleet-btn ${dailyFleetType === 'external' ? 'is-active' : ''}`} onClick={() => setDailyFleetType('external')}>外部车辆</button></div></div>
{boardScope === 'global' ? <div className="ehb-mobile-filter-field"><span>车辆范围</span><div className="ehb-fleet-segmented"><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-fleet-btn ${dailyFleetType === 'all' ? 'is-active' : ''}`} onClick={() => setDailyFleetType('all')}>全部车辆</button><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-fleet-btn ${dailyFleetType === 'own' ? 'is-active' : ''}`} onClick={() => setDailyFleetType('own')}>羚牛车辆</button><button type="button" style={ACCESSIBLE_CONTROL_STYLE} className={`ehb-fleet-btn ${dailyFleetType === 'external' ? 'is-active' : ''}`} onClick={() => setDailyFleetType('external')}>外部车辆</button></div></div> : null}
<button type="button" className="ehb-mobile-refresh" onClick={handleRefreshData} disabled={dailyRefreshing} aria-busy={dailyRefreshing}><RefreshCw size={14} className={dailyRefreshing ? 'is-spinning' : ''} aria-hidden />{dailyRefreshing ? '加载中…' : '刷新数据'}</button>
</>
)}
@@ -3,7 +3,7 @@
* 单站日报明细 · 对齐汇报 Excel 精简表集
* 近7日红涨绿跌 · 月环比同色 · 列表最多10条可展开
*/
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ArrowLeft, ChevronDown, ChevronUp, Download, RefreshCw } from 'lucide-react';
import { downloadExcelAoa } from '../../common/download-xls';
import { MobileListFullscreenButton } from '../../common/MobileListFullscreenButton';
@@ -12,7 +12,7 @@ import {
type StationCashIntakeDay,
} from '../../common/energy-spot-cash-intake';
import { fetchHydrogenStationBoard } from '../../../../modules/energy/api';
import { fetchH2BiDrill } from '../../../../modules/energy/hydrogen-bi-v2/api';
import { fetchAllH2BiDrillRecords } from '../../../../modules/energy/hydrogen-bi-v2/api';
import type { HydrogenStationBoardResponse } from '../../../../modules/energy/types';
import {
monthTotals,
@@ -20,7 +20,7 @@ import {
} from './data/mockStationDaily';
import { SdCustomerMultiSelect } from './SdCustomerMultiSelect';
import { SdDateRangePicker } from './SdDateRangePicker';
import { customerMonthLabel, customerMonthRange } from './station-month-range';
import { customerMonthLabel, customerMonthRange, dateRangeLabel } from './station-month-range';
const ROW_LIMIT = 10;
@@ -112,34 +112,32 @@ export const StationDailyDetailView: React.FC<{
const [mobileDetailTab, setMobileDetailTab] = useState<'daily' | 'customer' | 'balance' | 'cash'>('daily');
const [mobileMonthKey, setMobileMonthKey] = useState<string>('');
const [liveBoard, setLiveBoard] = useState<HydrogenStationBoardResponse | null>(null);
const [liveRecords, setLiveRecords] = useState<Record<string, unknown>[]>([]);
const [exporting, setExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
const exportController = useRef<AbortController | null>(null);
const [liveError, setLiveError] = useState<string | null>(null);
const [liveLoading, setLiveLoading] = useState(true);
useEffect(() => {
let active = true;
exportController.current?.abort();
exportController.current = null;
setExporting(false);
setExportError(null);
setLiveError(null);
setLiveLoading(true);
// 新的站点或日期范围不能沿用旧响应;否则月列已变而数值仍属上一查询。
setLiveBoard(null);
setLiveRecords([]);
Promise.all([
fetchHydrogenStationBoard({ startDate: rangeStart, endDate: rangeEnd, stationId: Number(stationId) }),
fetchH2BiDrill({
year: Number(rangeStart.slice(0, 4)), startDate: rangeStart, endDate: rangeEnd,
vehicleScope: 'all', verifyScope: 'all', stationId, groupBy: 'record', page: 1, pageSize: 500,
}),
]).then(([board, drill]) => {
fetchHydrogenStationBoard({ startDate: rangeStart, endDate: rangeEnd, stationId: Number(stationId) }).then((board) => {
if (!active) return;
setLiveBoard(board);
setLiveRecords(drill.records as Record<string, unknown>[]);
setLiveLoading(false);
}).catch((reason: unknown) => {
if (!active) return;
setLiveError(reason instanceof Error ? reason.message : '站点详情加载失败');
setLiveLoading(false);
});
return () => { active = false; };
return () => { active = false; exportController.current?.abort(); };
}, [stationId, rangeStart, rangeEnd, updatedAt]);
const liveStation = liveBoard?.stations.find((station) => String(station.id) === String(stationId));
@@ -234,19 +232,6 @@ export const StationDailyDetailView: React.FC<{
const custVisible = expandCust ? custCells : custCells.slice(0, ROW_LIMIT);
const feeVisible = expandCust ? feeCells : feeCells.slice(0, ROW_LIMIT);
const vehicleRows = useMemo(
() => liveRecords.map((row) => ({
date: String(row.refuelTime ?? '').slice(0, 10),
plateNo: String(row.plateNo ?? '未关联车牌'),
customerName: String(row.customerName ?? '未关联客户'),
fleet: row.vehicleScope === 'lingniu' ? 'own' : 'external',
quantityKg: Number(row.kg) || 0,
unitPrice: Number(row.costPrice) || 0,
amountYuan: Number(row.cost) || 0,
})),
[liveRecords],
);
const cashDays: StationCashIntakeDay[] = useMemo(() => {
void cashTick;
return (liveBoard?.selected?.daily ?? []).filter((row) => row.paymentAmount > 0).map((row) => ({
@@ -294,7 +279,27 @@ export const StationDailyDetailView: React.FC<{
const maxKg = Math.max(...volumeRows.map((r) => r.quantityKg), 1);
const hoverRow = hoverDate ? volumeRows.find((r) => r.date === hoverDate) : null;
const handleExport = () => {
const handleExport = async () => {
if (liveLoading || liveError || !liveBoard || exportController.current) return;
const controller = new AbortController();
exportController.current = controller;
setExporting(true);
setExportError(null);
try {
const result = await fetchAllH2BiDrillRecords({
year: Number(rangeStart.slice(0, 4)), startDate: rangeStart, endDate: rangeEnd,
vehicleScope: 'all', verifyScope: 'all', stationId,
}, { signal: controller.signal });
if (controller.signal.aborted) return;
const vehicleRows = result.records.map((row) => ({
date: String(row.time ?? '').slice(0, 10),
plateNo: String(row.plateNo ?? '未关联车牌'),
customerName: String(row.customerName ?? '未关联客户'),
fleet: row.vehicleScope === 'lingniu' ? 'own' : 'external',
quantityKg: Number(row.kg) || 0,
unitPrice: Number(row.unitPrice) || 0,
amountYuan: Number(row.cost) || 0,
}));
const monthHeads = customerMonthKeys.map(customerMonthLabel);
const aoa: (string | number)[][] = [
[`${stationName} · 站日报(查询日期 ${asOf})`],
@@ -335,7 +340,7 @@ export const StationDailyDetailView: React.FC<{
['充值日期', '客户', '付款方式', '金额(元)'],
...cashLines.map((l) => [l.bizDate, l.customerName, SPOT_PAY_METHOD_LABEL[l.payMethod], l.amount]),
[],
['车辆加氢明细(由汇总派生 · 取证)'],
['车辆加氢明细(查询区间全部真实账本记录)', result.records.length],
['日期', '车牌', '客户', '归属', '加氢量(Kg)', '单价', '金额(元)'],
...vehicleRows.map((r) => [
r.date,
@@ -348,6 +353,14 @@ export const StationDailyDetailView: React.FC<{
]),
];
downloadExcelAoa(aoa, `站日报取证_${stationName}_查询${asOf}.xlsx`, '站日报取证');
} catch (reason) {
if (!controller.signal.aborted) setExportError(reason instanceof Error ? reason.message : '导出失败,请重试');
} finally {
if (exportController.current === controller) {
exportController.current = null;
setExporting(false);
}
}
};
const renderMonthCells = (
@@ -428,12 +441,14 @@ export const StationDailyDetailView: React.FC<{
<RefreshCw size={16} aria-hidden />
刷新
</button>
<button type="button" className="sd-btn sd-btn--primary ehb-hide-h5" onClick={handleExport}>
<button type="button" className="sd-btn sd-btn--primary ehb-hide-h5" disabled={liveLoading || Boolean(liveError) || exporting} onClick={handleExport}>
<Download size={15} aria-hidden />
导出取证
{exporting ? '正在读取全部记录…' : '导出取证'}
</button>
{exporting ? <button type="button" className="sd-btn sd-btn--ghost" onClick={() => exportController.current?.abort()}>取消导出</button> : null}
</div>
</header>
{exportError ? <div role="alert" className="ehb-empty">导出失败:{exportError}。未生成文件,请缩小日期范围或重试。</div> : null}
{liveError ? (
<div className="ehb-empty" role="alert">站点详情加载失败:{liveError}</div>
@@ -454,29 +469,29 @@ export const StationDailyDetailView: React.FC<{
</div>
</div>
<div className="sd-hero-kpi">
<div className="sd-hero-kpi__label">近 10 日加氢</div>
<div className="sd-hero-kpi__label">查询区间加氢</div>
<div className="sd-hero-kpi__value">
{kg(rangeKg)}
<span className="sd-unit">Kg</span>
</div>
<div className="sd-hero-kpi__sub">¥{money(rangeAmt)}</div>
<div className="sd-hero-kpi__sub">¥{money(rangeAmt)} · {dateRangeLabel(startDate, end)}</div>
</div>
<div className="sd-hero-kpi">
<div className="sd-hero-kpi__label">本月加氢</div>
<div className="sd-hero-kpi__label">区间内本月加氢</div>
<div className="sd-hero-kpi__value">
{kg(monthKg)}
<span className="sd-unit">Kg</span>
</div>
<div className="sd-hero-kpi__sub">{asOf.slice(0, 7)}</div>
<div className="sd-hero-kpi__sub">{asOf.slice(0, 7)} · 仅计入查询区间</div>
</div>
<div className="sd-hero-kpi">
<div className="sd-hero-kpi__label">近 10 日现结</div>
<div className="sd-hero-kpi__label">查询区间现结</div>
<div className="sd-hero-kpi__value">
{money(cashTotal)}
<span className="sd-unit">元</span>
</div>
<div className="sd-hero-kpi__sub">
{cashDays.length ? `${cashDays.length} 天有进账` : '0 天有进账'}
{cashDays.length ? `${cashDays.length} 天有进账 · ${dateRangeLabel(startDate, end)}` : `0 天有进账 · ${dateRangeLabel(startDate, end)}`}
</div>
</div>
</div>
@@ -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);
});
@@ -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}`;
}