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