feat(energy): optimize mobile hydrogen daily and customer lists
ci/woodpecker/push/woodpecker Pipeline was canceled
ci/woodpecker/push/woodpecker Pipeline was canceled
Co-authored-by: HiFox Agent <agents-noreply@hifox.com>
This commit is contained in:
@@ -140,7 +140,9 @@ export function registerHydrogenStationBoardRoute(
|
||||
kg: dailyKgByStation.get(id)?.get(date) ?? 0,
|
||||
})),
|
||||
};
|
||||
}).filter(station => station.recordCount > 0);
|
||||
}).filter(station => station.recordCount > 0
|
||||
|| station.name.includes('东鹏大道')
|
||||
|| (station.name.includes('佛山南海') && station.name.includes('羚牛')));
|
||||
|
||||
let selected = null;
|
||||
if (stationId) {
|
||||
|
||||
@@ -460,7 +460,28 @@ test("氢能 BI v2 月度上下文换算为完整自然月并沿用至明细", a
|
||||
]);
|
||||
});
|
||||
|
||||
test("单站看板仅保留有加氢记录的站点并合并区间现结流水", async () => {
|
||||
test("指定单站即使无记录仍可选择,不伪造加氢量", async () => {
|
||||
const app = new Hono();
|
||||
registerHydrogenStationBoardRoute(app, {
|
||||
hydrogenPool: { query: createQueryMock([
|
||||
[
|
||||
{ id: 341, name: "佛山南海羚牛加氢站", kg: 0, fee: 0, recordCount: 0 },
|
||||
{ id: 342, name: "广州交投东鹏大道加氢站", kg: 0, fee: 0, recordCount: 0 },
|
||||
{ id: 343, name: "其他零业务站", kg: 0, fee: 0, recordCount: 0 },
|
||||
], [], [], [], [],
|
||||
], []) },
|
||||
cached: createCachedMock([]),
|
||||
} as unknown as HydrogenStationBoardDependencies);
|
||||
const response = await app.request("/hydrogen/station-board?startDate=2026-08-16&endDate=2026-08-17");
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.deepEqual(payload.stations.map((station: { id: number }) => station.id), [341, 342]);
|
||||
assert.equal(payload.summary.totalKg, 0);
|
||||
assert.equal(payload.summary.activeStationCount, 0);
|
||||
assert.ok(payload.stations.every((station: { dailyKg: { kg: number }[] }) => station.dailyKg.every(row => row.kg === 0)));
|
||||
});
|
||||
|
||||
test("单站看板过滤非指定零业务站并合并区间现结流水", async () => {
|
||||
const calls: QueryCall[] = [];
|
||||
const cacheCalls: CacheCall[] = [];
|
||||
const stationBoardApp = new Hono();
|
||||
|
||||
+26
-55
@@ -13,6 +13,8 @@ import {
|
||||
} from '../../common/energy-spot-cash-intake';
|
||||
import { fetchHydrogenStationBoard } from '../../../../modules/energy/api';
|
||||
import { fetchAllH2BiDrillRecords } from '../../../../modules/energy/hydrogen-bi-v2/api';
|
||||
import { PrototypeDrillModal } from '../../../../modules/energy/hydrogen-bi-v2/prototype-real-drills';
|
||||
import { MobileDailyList, MobileCustomerMonthList } from './StationMobileLists';
|
||||
import type { HydrogenStationBoardResponse } from '../../../../modules/energy/types';
|
||||
import {
|
||||
monthTotals,
|
||||
@@ -111,6 +113,7 @@ export const StationDailyDetailView: React.FC<{
|
||||
const [expandCash, setExpandCash] = useState(false);
|
||||
const [mobileDetailTab, setMobileDetailTab] = useState<'daily' | 'customer' | 'balance' | 'cash'>('daily');
|
||||
const [mobileMonthKey, setMobileMonthKey] = useState<string>('');
|
||||
const [orderDate, setOrderDate] = useState<string | null>(null);
|
||||
const [liveBoard, setLiveBoard] = useState<HydrogenStationBoardResponse | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
@@ -124,11 +127,12 @@ export const StationDailyDetailView: React.FC<{
|
||||
exportController.current = null;
|
||||
setExporting(false);
|
||||
setExportError(null);
|
||||
setOrderDate(null);
|
||||
setLiveError(null);
|
||||
setLiveLoading(true);
|
||||
// 新的站点或日期范围不能沿用旧响应;否则月列已变而数值仍属上一查询。
|
||||
setLiveBoard(null);
|
||||
fetchHydrogenStationBoard({ startDate: rangeStart, endDate: rangeEnd, stationId: Number(stationId) }).then((board) => {
|
||||
fetchHydrogenStationBoard({ startDate: rangeStart, endDate: rangeEnd, stationId: Number(stationId), force: cashTick > 0 }).then((board) => {
|
||||
if (!active) return;
|
||||
setLiveBoard(board);
|
||||
setLiveLoading(false);
|
||||
@@ -138,7 +142,7 @@ export const StationDailyDetailView: React.FC<{
|
||||
setLiveLoading(false);
|
||||
});
|
||||
return () => { active = false; exportController.current?.abort(); };
|
||||
}, [stationId, rangeStart, rangeEnd, updatedAt]);
|
||||
}, [stationId, rangeStart, rangeEnd, updatedAt, cashTick]);
|
||||
|
||||
const liveStation = liveBoard?.stations.find((station) => String(station.id) === String(stationId));
|
||||
const stationName = liveStation?.name || stationId;
|
||||
@@ -214,18 +218,20 @@ export const StationDailyDetailView: React.FC<{
|
||||
return allCustCells.filter((c) => set.has(c.customerName));
|
||||
}, [allCustCells, selectedCustomers]);
|
||||
|
||||
const feeCells = useMemo(() => {
|
||||
const allowed = new Set(custCells.map((row) => row.customerName));
|
||||
const allFeeCells = useMemo(() => {
|
||||
const byCustomer = new Map<string, Record<string, number>>();
|
||||
for (const row of liveBoard?.selected?.customerMonths ?? []) {
|
||||
if (!allowed.has(row.customerName)) continue;
|
||||
const months = byCustomer.get(row.customerName) ?? {};
|
||||
const monthKey = String(row.month).slice(0, 7);
|
||||
months[monthKey] = row.fee;
|
||||
byCustomer.set(row.customerName, months);
|
||||
}
|
||||
return [...byCustomer].map(([customerName, months]) => ({ stationId, customerName, months }));
|
||||
}, [custCells, liveBoard, stationId]);
|
||||
}, [liveBoard, stationId]);
|
||||
const feeCells = useMemo(() => {
|
||||
const allowed = new Set(custCells.map((row) => row.customerName));
|
||||
return allFeeCells.filter((row) => allowed.has(row.customerName));
|
||||
}, [custCells, allFeeCells]);
|
||||
const kgMonthTot = useMemo(() => monthTotals(custCells, customerMonthKeys), [custCells, customerMonthKeys]);
|
||||
const feeMonthTot = useMemo(() => monthTotals(feeCells, customerMonthKeys), [feeCells, customerMonthKeys]);
|
||||
|
||||
@@ -406,7 +412,7 @@ export const StationDailyDetailView: React.FC<{
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sd-detail" data-annotation-id="station-daily-detail">
|
||||
<div className="sd-detail sd-detail--mobile-pilot" data-annotation-id="station-daily-detail">
|
||||
<header className="sd-detail-top">
|
||||
<div className="sd-detail-top__lead">
|
||||
<button type="button" className="sd-btn sd-btn--ghost" onClick={onBack}>
|
||||
@@ -521,42 +527,13 @@ export const StationDailyDetailView: React.FC<{
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{mobileDetailTab === 'customer' ? (
|
||||
<div className="sd-mobile-month-controls">
|
||||
<select aria-label="月份" value={mobileMonthKey} onChange={(event) => setMobileMonthKey(event.target.value)}>
|
||||
{customerMonthKeys.map((month) => <option key={month} value={month}>{customerMonthLabel(month)}</option>)}
|
||||
</select>
|
||||
<div className="sd-mobile-customer-filter">
|
||||
<SdCustomerMultiSelect options={customerOptions} value={selectedCustomers} onChange={setSelectedCustomers} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<section className={`sd-panel sd-panel--block sd-mobile-detail-panel ${mobileDetailTab === 'daily' ? 'is-active' : ''}`} data-mobile-fullscreen-list>
|
||||
<h2 className="sd-panel__title">加氢站每日加氢量汇总(近 7 日)</h2>
|
||||
<div className="sd-mobile-record-list sd-mobile-daily-list">
|
||||
{volume7.map((row, index) => {
|
||||
const previous = index > 0 ? volume7[index - 1].quantityKg : null;
|
||||
const delta = previous == null ? null : row.quantityKg - previous;
|
||||
return (
|
||||
<article key={row.date} className="sd-mobile-record">
|
||||
<div className="sd-mobile-record__lead">
|
||||
<strong>{padYmd(row.date).slice(5)}</strong>
|
||||
<span>{row.vehicleCount} 车次</span>
|
||||
</div>
|
||||
<div className="sd-mobile-record__value">
|
||||
<strong>{kg(row.quantityKg)} <small>Kg</small></strong>
|
||||
<span className={stockDeltaClass(row.quantityKg, previous)}>
|
||||
{delta == null ? '首日' : `较前日 ${delta > 0 ? '+' : ''}${kg(delta)}`}
|
||||
<DeltaMark curr={row.quantityKg} prev={previous} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="sd-mobile-record__meta">金额 ¥{money(row.amountYuan)} · 单价 ¥{row.unitPrice}/Kg</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<MobileDailyList key={`${stationId}|${rangeStart}|${rangeEnd}`}
|
||||
rows={volume7} allRows={volumeRows} loading={liveLoading} error={liveError}
|
||||
onOpenOrders={setOrderDate} />
|
||||
<div className="sd-table-scroll">
|
||||
<table className="sd-bi-table sd-bi-table--fill">
|
||||
<thead>
|
||||
@@ -701,22 +678,11 @@ export const StationDailyDetailView: React.FC<{
|
||||
onChange={setSelectedCustomers}
|
||||
/>
|
||||
</div>
|
||||
<div className="sd-mobile-record-list sd-mobile-customer-combined-list">
|
||||
{custVisible.map((customer) => {
|
||||
const feeCustomer = feeCells.find((item) => item.customerName === customer.customerName);
|
||||
const volumeValue = customer.months[mobileMonthKey] ?? 0;
|
||||
const feeValue = feeCustomer?.months[mobileMonthKey] ?? 0;
|
||||
return (
|
||||
<article key={customer.customerName} className="sd-mobile-record">
|
||||
<div className="sd-mobile-record__lead"><strong>{customer.customerName}</strong><span>{customerMonthLabel(mobileMonthKey)}</span></div>
|
||||
<div className="sd-mobile-record__metrics">
|
||||
<div className={`sd-mobile-record__metric is-volume ${volumeValue === 0 ? 'is-zero' : ''}`}><strong>{kg(volumeValue)}</strong><span>Kg</span></div>
|
||||
<div className={`sd-mobile-record__metric is-fee ${feeValue === 0 ? 'is-zero' : ''}`}><strong>¥{money(feeValue)}</strong><span>加氢费</span></div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<MobileCustomerMonthList key={stationId} months={customerMonthKeys}
|
||||
volumeCustomers={allCustCells} feeCustomers={allFeeCells}
|
||||
month={mobileMonthKey} onMonthChange={setMobileMonthKey}
|
||||
metric={customerMonthlyMetric} onMetricChange={setCustomerMonthlyMetric}
|
||||
loading={liveLoading} error={liveError} />
|
||||
<div className="sd-table-scroll sd-table-scroll--matrix sd-desktop-matrix-table">
|
||||
<table className={`sd-bi-table sd-bi-table--matrix ${customerMonthlyMetric === 'fee' ? 'is-amount' : ''}`}>
|
||||
<thead>
|
||||
@@ -894,6 +860,11 @@ export const StationDailyDetailView: React.FC<{
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{orderDate ? <PrototypeDrillModal kind="records"
|
||||
label={`${stationName} · ${orderDate} 加氢订单`}
|
||||
query={{ year: Number(orderDate.slice(0, 4)), startDate: orderDate, endDate: orderDate,
|
||||
date: orderDate, stationId, vehicleScope: 'all', verifyScope: 'all' }}
|
||||
onClose={() => setOrderDate(null)} /> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import './station-mobile-lists.css';
|
||||
import {
|
||||
customerMonthTotal,
|
||||
customerMetricValue,
|
||||
formatKg,
|
||||
formatMoney,
|
||||
mobileCustomerRows,
|
||||
mobileDailyRows,
|
||||
monthLabel,
|
||||
type CustomerMetric,
|
||||
type StationDailyVolumeRow,
|
||||
type StationMonthlyCustomer,
|
||||
} from './station-mobile-list-model';
|
||||
|
||||
export type { CustomerMetric, StationDailyVolumeRow, StationMonthlyCustomer } from './station-mobile-list-model';
|
||||
|
||||
export interface MobileDailyListProps {
|
||||
/** The selected near-seven-day rows; `allRows` remains the source for actual prior-day comparison. */
|
||||
rows: StationDailyVolumeRow[];
|
||||
allRows: StationDailyVolumeRow[];
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
onOpenOrders: (date: string) => void;
|
||||
}
|
||||
|
||||
export function MobileDailyList({ rows, allRows, loading = false, error = null, onOpenOrders }: MobileDailyListProps) {
|
||||
const dailyRows = useMemo(() => mobileDailyRows(rows, allRows), [rows, allRows]);
|
||||
if (loading) return <section className="sd-mobile-list-pilot" aria-busy="true">日报正在加载…</section>;
|
||||
if (error) return <section className="sd-mobile-list-pilot sd-mobile-list__state is-error" role="alert">日报加载失败:{error}</section>;
|
||||
if (!dailyRows.length) return <section className="sd-mobile-list-pilot sd-mobile-list__state">暂无日报数据</section>;
|
||||
|
||||
return <section className="sd-mobile-list-pilot" aria-label="近七日日报">
|
||||
<p className="sd-mobile-list__hint">最新日期在前 · 点日期展开车次、单价和订单</p>
|
||||
<div className="sd-mobile-list__head sd-mobile-daily__head"><span>日期</span><span>加氢量</span><span>金额</span></div>
|
||||
<div className="sd-mobile-list__rows">
|
||||
{dailyRows.map((row) => {
|
||||
const hasRecord = row.vehicleCount > 0;
|
||||
const delta = row.previousKg == null ? '无前日数据' : `${row.quantityKg - row.previousKg >= 0 ? '+' : ''}${formatKg(row.quantityKg - row.previousKg)} kg`;
|
||||
return <details className="sd-mobile-list__detail" key={row.date}>
|
||||
<summary className="sd-mobile-daily__row">
|
||||
<span><strong>{row.date.slice(5)}</strong><small>{row.date.slice(0, 4)}</small></span>
|
||||
<span className={hasRecord ? '' : 'is-zero'}><strong>{formatKg(row.quantityKg)}</strong><small>kg</small></span>
|
||||
<span className={hasRecord ? '' : 'is-zero'}><strong>¥{formatMoney(row.amountYuan)}</strong>{!hasRecord && <small>无记录</small>}</span>
|
||||
</summary>
|
||||
<div className="sd-mobile-list__expanded">
|
||||
<span>车次:{row.vehicleCount} 次</span><span>单价:¥{formatMoney(row.unitPrice)}/kg</span><span>较昨日:{delta}</span>
|
||||
<button type="button" onClick={() => onOpenOrders(row.date)}>查看当天订单</button>
|
||||
</div>
|
||||
</details>;
|
||||
})}
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
export interface MobileCustomerMonthListProps {
|
||||
/** Continuous YYYY-MM values, including months with no business records. */
|
||||
months: string[];
|
||||
volumeCustomers: StationMonthlyCustomer[];
|
||||
feeCustomers: StationMonthlyCustomer[];
|
||||
month: string;
|
||||
onMonthChange: (month: string) => void;
|
||||
metric: CustomerMetric;
|
||||
onMetricChange: (metric: CustomerMetric) => void;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export function MobileCustomerMonthList({ months, volumeCustomers, feeCustomers, month, onMonthChange, metric, onMetricChange, loading = false, error = null }: MobileCustomerMonthListProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const customers = metric === 'volume' ? volumeCustomers : feeCustomers;
|
||||
const sortedCustomers = useMemo(() => mobileCustomerRows(customers, month, search), [customers, month, search]);
|
||||
const total = useMemo(() => customerMonthTotal(customers, month), [customers, month]);
|
||||
if (loading) return <section className="sd-mobile-list-pilot" aria-busy="true">客户月度数据正在加载…</section>;
|
||||
if (error) return <section className="sd-mobile-list-pilot sd-mobile-list__state is-error" role="alert">客户月度加载失败:{error}</section>;
|
||||
|
||||
return <section className="sd-mobile-list-pilot" aria-label="客户月度排行">
|
||||
<div className="sd-mobile-customer__controls">
|
||||
<select aria-label="选择月份" value={month} onChange={(event) => onMonthChange(event.target.value)}>
|
||||
{months.map((item) => <option value={item} key={item}>{monthLabel(item)}</option>)}
|
||||
</select>
|
||||
<div className="sd-mobile-customer__metric" aria-label="选择指标">
|
||||
<button type="button" aria-pressed={metric === 'volume'} className={metric === 'volume' ? 'is-active' : ''} onClick={() => onMetricChange('volume')}>数量</button>
|
||||
<button type="button" aria-pressed={metric === 'fee'} className={metric === 'fee' ? 'is-active' : ''} onClick={() => onMetricChange('fee')}>金额</button>
|
||||
</div>
|
||||
<strong>{monthLabel(month)}合计:{metric === 'volume' ? `${formatKg(total)} kg` : `¥${formatMoney(total)}`}</strong>
|
||||
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索全部客户" aria-label="搜索全部客户" />
|
||||
</div>
|
||||
<p className="sd-mobile-list__hint">点客户展开近 12 个月 · 合计不随搜索缩减</p>
|
||||
{!sortedCustomers.length ? <p className="sd-mobile-list__state">{search ? '未找到匹配客户' : '该月暂无客户数据'}</p> : <div className="sd-mobile-list__rows">
|
||||
{sortedCustomers.map((customer) => <details className="sd-mobile-list__detail sd-mobile-customer__detail" key={customer.customerName}>
|
||||
<summary><span>{customer.customerName}</span><strong className={customerMetricValue(customer, month) === 0 ? 'is-zero' : ''}>{metric === 'volume' ? `${formatKg(customerMetricValue(customer, month))} kg` : `¥${formatMoney(customerMetricValue(customer, month))}`}</strong></summary>
|
||||
<div className="sd-mobile-customer__trend" aria-label={`${customer.customerName}十二个月趋势`}>
|
||||
{months.map((item) => {
|
||||
const value = customerMetricValue(customer, item);
|
||||
const max = Math.max(...months.map((key) => customerMetricValue(customer, key)), 0);
|
||||
const width = max > 0 ? `${(value / max) * 100}%` : '0%';
|
||||
return <div key={item}><span>{item}</span><i aria-hidden style={{ width }} className={value === 0 ? 'is-zero' : ''} /><strong>{metric === 'volume' ? `${formatKg(value)} kg` : `¥${formatMoney(value)}`}</strong></div>;
|
||||
})}
|
||||
</div>
|
||||
</details>)}
|
||||
</div>}
|
||||
</section>;
|
||||
}
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { customerMonthTotal, formatMoney, mobileCustomerRows, mobileDailyRows } from './station-mobile-list-model';
|
||||
|
||||
test('日列表倒序且从全量查询读取实际前日值', () => {
|
||||
const rows = [{ date: '2026-08-11', quantityKg: 9, amountYuan: 90, vehicleCount: 1, unitPrice: 10 }];
|
||||
const result = mobileDailyRows(rows, [...rows, { date: '2026-08-10', quantityKg: 7, amountYuan: 70, vehicleCount: 1, unitPrice: 10 }]);
|
||||
assert.equal(result[0].previousKg, 7);
|
||||
});
|
||||
|
||||
test('客户按当前月指标排序,搜索覆盖全部客户且零值保持零', () => {
|
||||
const customers = [
|
||||
{ customerName: '甲运输', months: { '2026-08': 0 } },
|
||||
{ customerName: '乙物流', months: { '2026-08': 12 } },
|
||||
];
|
||||
assert.deepEqual(mobileCustomerRows(customers, '2026-08', '').map((item) => item.customerName), ['乙物流', '甲运输']);
|
||||
assert.equal(customerMonthTotal(customers, '2026-08'), 12);
|
||||
assert.deepEqual(mobileCustomerRows(customers, '2026-08', '甲').map((item) => item.customerName), ['甲运输']);
|
||||
});
|
||||
|
||||
test('日报区分真实前日零和缺失前日,倒序展示不改变源数组', () => {
|
||||
const rows = [
|
||||
{ date: '2026-03-01', quantityKg: 9, amountYuan: 90, vehicleCount: 1, unitPrice: 10 },
|
||||
{ date: '2026-03-02', quantityKg: 0, amountYuan: 0, vehicleCount: 0, unitPrice: 0 },
|
||||
];
|
||||
const result = mobileDailyRows(rows, [...rows, { date: '2026-02-28', quantityKg: 0, amountYuan: 0, vehicleCount: 0, unitPrice: 0 }]);
|
||||
assert.deepEqual(result.map(row => row.date), ['2026-03-02', '2026-03-01']);
|
||||
assert.equal(result[1].previousKg, 0);
|
||||
assert.equal(mobileDailyRows(rows, rows)[1].previousKg, null);
|
||||
assert.equal(rows[0].date, '2026-03-01');
|
||||
});
|
||||
|
||||
test('货币统一保留两位小数,包括真实零', () => {
|
||||
assert.equal(formatMoney(0), '0.00');
|
||||
assert.equal(formatMoney(1234.5), '1,234.50');
|
||||
});
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/** Mobile list derivations are deliberately data-only so the view never invents zeroes. */
|
||||
export interface StationDailyVolumeRow {
|
||||
date: string;
|
||||
quantityKg: number;
|
||||
amountYuan: number;
|
||||
vehicleCount: number;
|
||||
unitPrice: number;
|
||||
}
|
||||
|
||||
export interface StationMonthlyCustomer {
|
||||
customerName: string;
|
||||
months: Record<string, number>;
|
||||
}
|
||||
|
||||
export type CustomerMetric = 'volume' | 'fee';
|
||||
|
||||
export function formatKg(value: number): string {
|
||||
return Number(value || 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
export function formatMoney(value: number): string {
|
||||
return Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
/** Latest first; allRows supplies the true previous-day quantity even outside the seven-day view. */
|
||||
export function mobileDailyRows(rows: StationDailyVolumeRow[], allRows: StationDailyVolumeRow[]) {
|
||||
const quantitiesByDate = new Map(allRows.map((row) => [row.date, row.quantityKg]));
|
||||
return [...rows]
|
||||
.sort((left, right) => right.date.localeCompare(left.date))
|
||||
.map((row) => {
|
||||
const previous = new Date(`${row.date}T00:00:00Z`);
|
||||
previous.setUTCDate(previous.getUTCDate() - 1);
|
||||
const previousDate = previous.toISOString().slice(0, 10);
|
||||
const previousKg = quantitiesByDate.get(previousDate);
|
||||
return { ...row, previousKg: Number.isFinite(previousKg) ? previousKg : null };
|
||||
});
|
||||
}
|
||||
|
||||
export function customerMetricValue(customer: StationMonthlyCustomer, month: string): number {
|
||||
const value = customer.months[month];
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
export function mobileCustomerRows(
|
||||
customers: StationMonthlyCustomer[],
|
||||
month: string,
|
||||
search: string,
|
||||
): StationMonthlyCustomer[] {
|
||||
const keyword = search.trim().toLocaleLowerCase('zh-CN');
|
||||
return customers
|
||||
.filter((customer) => !keyword || customer.customerName.toLocaleLowerCase('zh-CN').includes(keyword))
|
||||
.sort((left, right) => customerMetricValue(right, month) - customerMetricValue(left, month)
|
||||
|| left.customerName.localeCompare(right.customerName, 'zh-CN'));
|
||||
}
|
||||
|
||||
export function customerMonthTotal(customers: StationMonthlyCustomer[], month: string): number {
|
||||
return customers.reduce((total, customer) => total + customerMetricValue(customer, month), 0);
|
||||
}
|
||||
|
||||
export function monthLabel(month: string): string {
|
||||
const matched = /^(\d{4})-(\d{2})$/.exec(month);
|
||||
return matched ? `${matched[1]}年${Number(matched[2])}月` : month;
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/* Pilot is intentionally isolated: the host chooses where to render it. */
|
||||
.sd-mobile-list-pilot { display: none; }
|
||||
.sd-mobile-list__hint { margin: 4px 0 8px; color: #64748b; font-size: 11px; line-height: 1.5; }
|
||||
@media (max-width: 767px) {
|
||||
.sd-mobile-list-pilot { display: block; box-sizing: border-box; width: 100%; color: #24344e; font-family: var(--sd-font, -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif); }
|
||||
.sd-mobile-list__head, .sd-mobile-daily__row { display: grid; grid-template-columns: .82fr .9fr 1.28fr; align-items: center; gap: 7px; }
|
||||
.sd-mobile-list__head { padding: 0 2px 6px; color: #71819a; font-size: 10px; }
|
||||
.sd-mobile-list__head span:not(:first-child), .sd-mobile-daily__row > span:not(:first-child) { text-align: right; }
|
||||
.sd-mobile-list__detail { border-top: 1px solid #edf1f6; }
|
||||
.sd-mobile-list__detail summary { list-style: none; cursor: pointer; min-height: 48px; }
|
||||
.sd-mobile-list__detail summary::-webkit-details-marker { display: none; }
|
||||
.sd-mobile-daily__row > span { display: flex; min-width: 0; align-items: baseline; justify-content: flex-end; gap: 3px; }
|
||||
.sd-mobile-daily__row > span:first-child { justify-content: flex-start; }
|
||||
.sd-mobile-daily__row strong { color: #1e3556; font-size: 13px; font-variant-numeric: tabular-nums; }
|
||||
.sd-mobile-daily__row small { color: #75859c; font-size: 9px; }
|
||||
.sd-mobile-list-pilot .is-zero { color: #94a3b8; }
|
||||
.sd-mobile-list__expanded { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; padding: 0 0 10px; color: #64748b; font-size: 11px; }
|
||||
.sd-mobile-list__expanded span:last-of-type { grid-column: 1 / -1; }
|
||||
.sd-mobile-list__expanded button { grid-column: 1 / -1; min-height: 44px; border: 1px solid #bfdbfe; border-radius: 8px; background: #eff6ff; color: #2563eb; font: inherit; font-weight: 700; }
|
||||
.sd-mobile-list__state { margin: 0; color: #64748b; font-size: 12px; text-align: center; }
|
||||
.sd-mobile-list__state.is-error { color: #b42318; }
|
||||
.sd-mobile-customer__controls { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.sd-mobile-customer__controls select, .sd-mobile-customer__controls input, .sd-mobile-customer__metric button { min-width: 0; min-height: 44px; border: 1px solid #d8e2ef; border-radius: 8px; background: #fff; color: #34445f; font: inherit; font-size: 12px; }
|
||||
.sd-mobile-customer__metric { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; }
|
||||
.sd-mobile-customer__metric button.is-active { border-color: #2563eb; background: #eff6ff; color: #2563eb; font-weight: 700; }
|
||||
.sd-mobile-customer__controls > strong, .sd-mobile-customer__controls input { grid-column: 1 / -1; }
|
||||
.sd-mobile-customer__controls > strong { color: #1e3556; font-size: 12px; }
|
||||
.sd-mobile-customer__controls input { box-sizing: border-box; padding: 0 10px; }
|
||||
.sd-mobile-customer__detail summary { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; }
|
||||
.sd-mobile-customer__detail summary span { display: -webkit-box; overflow: hidden; color: #263650; font-size: 12px; font-weight: 650; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
|
||||
.sd-mobile-customer__detail summary strong { color: #1e3556; font-size: 12px; font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.sd-mobile-customer__trend { display: grid; gap: 4px; padding: 0 0 10px; }
|
||||
.sd-mobile-customer__trend > div { display: grid; grid-template-columns: 50px minmax(0, 1fr) 82px; align-items: center; gap: 6px; color: #71819a; font-size: 10px; }
|
||||
.sd-mobile-customer__trend i { display: block; height: 5px; min-width: 0; border-radius: 99px; background: #60a5fa; }
|
||||
.sd-mobile-customer__trend i.is-zero { width: 0 !important; }
|
||||
.sd-mobile-customer__trend strong { color: #51627b; font-size: 10px; font-variant-numeric: tabular-nums; text-align: right; }
|
||||
}
|
||||
@@ -2765,6 +2765,67 @@
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
/* Mobile list pilot: compact reading first; original tables remain for reconciliation. */
|
||||
@media (max-width: 767px) {
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-hub {
|
||||
display: block;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-tabs {
|
||||
display: block !important;
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
}
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-tabs__title {
|
||||
font-size: 14px;
|
||||
}
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-tabs .mobile-list-fullscreen-trigger {
|
||||
min-height: 44px;
|
||||
}
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-panel:not(.is-active),
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-trend-panel,
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-panel > .sd-table-scroll,
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-panel > .sd-panel__head-row,
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-panel > .sd-more-btn {
|
||||
display: none;
|
||||
}
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-panel.is-active {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-dual--ledger { display: contents; }
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-panel > .sd-panel__title {
|
||||
padding: 0 0 8px !important;
|
||||
font-size: 13px;
|
||||
}
|
||||
/* Non-pilot settlement/intake panels keep their existing truthful tables. */
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-dual--ledger .sd-table-scroll {
|
||||
display: block;
|
||||
}
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-hub[data-mobile-fullscreen-active="true"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
}
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-hub[data-mobile-fullscreen-active="true"] .sd-mobile-list-pilot {
|
||||
display: none !important;
|
||||
}
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-hub[data-mobile-fullscreen-active="true"] .sd-mobile-detail-panel.is-active {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-hub[data-mobile-fullscreen-active="true"] .sd-mobile-detail-panel.is-active > .sd-table-scroll,
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-hub[data-mobile-fullscreen-active="true"] .sd-mobile-detail-panel.is-active > .sd-panel__head-row,
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-hub[data-mobile-fullscreen-active="true"] .sd-mobile-detail-panel.is-active > .sd-more-btn {
|
||||
display: block;
|
||||
}
|
||||
.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-hub[data-mobile-fullscreen-active="true"] .sd-mobile-combined-fullscreen-table {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.sd-live-loading-overlay {
|
||||
position: fixed;
|
||||
z-index: 480;
|
||||
|
||||
Reference in New Issue
Block a user