diff --git a/src/modules/energy/HydrogenBoardChrome.tsx b/src/modules/energy/HydrogenBoardChrome.tsx deleted file mode 100644 index ca8b1e5..0000000 --- a/src/modules/energy/HydrogenBoardChrome.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import './styles/energy-bi-board.css'; - -export type HydrogenBoardScope = 'global' | 'station'; -export type HydrogenBoardView = 'daily' | 'overview'; - -export function HydrogenBoardNotice() { - return null; -} - -export default function HydrogenBoardChrome({ - scope, - view, - rangeText, - onScopeChange, - onViewChange, -}: { - scope: HydrogenBoardScope; - view: HydrogenBoardView; - rangeText?: string; - onScopeChange: (scope: HydrogenBoardScope) => void; - onViewChange: (view: HydrogenBoardView) => void; -}) { - return ( -
-
-
羚牛氢能 BI / 氢能
-
-

氢能经营看板

- {scope === 'global' && rangeText ? ( - - 📅 统计时间范围:{rangeText} - - ) : null} -
-
-
-
- - -
- {scope === 'global' ? ( -
- - -
- ) : null} -
-
- ); -} diff --git a/src/modules/energy/HydrogenDaily.tsx b/src/modules/energy/HydrogenDaily.tsx deleted file mode 100644 index a0077ae..0000000 --- a/src/modules/energy/HydrogenDaily.tsx +++ /dev/null @@ -1,304 +0,0 @@ -import { useEffect, useMemo, useState } from 'react'; -import { AlertTriangle, RefreshCw } from 'lucide-react'; -import { fetchHydrogenDaily, fetchHydrogenDailyDetail, type HydrogenVerifyScope } from './api'; -import type { CustomerType, DateQuickPick, HydrogenDailyDetailResponse, HydrogenDailyRow } from './types'; -import { - buildHydrogenDailyTrend, - filterHydrogenRowsByStation, - getHydrogenDailyStations, - getQuickRange, - getRangeModeLabel, - mergeHydrogenDailyRows, - normalizeRange, - summarizeHydrogenRows, - type HydrogenDailyBoardScope, - type HydrogenDailyVehicleScope, - type RangeMode, -} from './hydrogen-daily/model'; -import DailyRangeControls from './daily-range/DailyRangeControls'; -import { DailyDetailTable } from './hydrogen-daily/components/DailyDetailTable'; -import { DailyKpiGrid } from './hydrogen-daily/components/DailyKpiGrid'; -import { DailyTrendChart } from './hydrogen-daily/components/DailyTrendChart'; -import { StationDailyOverview } from './hydrogen-daily/components/StationDailyOverview'; -import { EmptyState, ErrorState, LoadingState } from '../../components/ui/surface'; -import './styles/energy-bi-board.css'; - -export interface HydrogenDailyProps { - scope?: HydrogenDailyBoardScope; - onRangeTextChange?: (rangeText: string) => void; -} - -interface DailyDatasets { - lingniu: HydrogenDailyRow[] | null; - external: HydrogenDailyRow[] | null; -} - -export interface DailyDetailState { - loading: boolean; - data: HydrogenDailyDetailResponse | null; - error: string | null; -} - -function formatUpdatedAt(date: Date): string { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - return `${year}-${month}-${day} ${hours}:${minutes}`; -} - -export default function HydrogenDaily({ scope = 'global', onRangeTextChange }: HydrogenDailyProps) { - const [vehicleScope, setVehicleScope] = useState('all'); - // Kept explicit even though this view has no verification toggle yet: drill - // requests always declare their data scope instead of silently defaulting. - const verifyScope: HydrogenVerifyScope = 'all'; - const [pick, setPick] = useState('last15'); - const [dateRange, setDateRange] = useState(() => getQuickRange('last15')); - const [selectedStationId, setSelectedStationId] = useState(null); - const [expanded, setExpanded] = useState>(new Set()); - const [highlightedDate, setHighlightedDate] = useState(null); - const [datasets, setDatasets] = useState({ lingniu: null, external: null }); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [refreshKey, setRefreshKey] = useState(0); - const [updatedAt, setUpdatedAt] = useState(null); - const [details, setDetails] = useState>({}); - - const effectiveRange = useMemo( - () => normalizeRange(dateRange.start, dateRange.end), - [dateRange.start, dateRange.end], - ); - - useEffect(() => { - onRangeTextChange?.(`${effectiveRange.start} 至 ${effectiveRange.end}`); - }, [effectiveRange.start, effectiveRange.end, onRangeTextChange]); - - useEffect(() => { - let cancelled = false; - setLoading(true); - setError(null); - const query = pick === 'custom' - ? { startDate: effectiveRange.start, endDate: effectiveRange.end } - : { range: pick }; - - Promise.all([ - fetchHydrogenDaily(query, 'lingniu'), - fetchHydrogenDaily(query, 'external'), - ]) - .then(([lingniu, external]) => { - if (cancelled) return; - setDatasets({ lingniu, external }); - setUpdatedAt(formatUpdatedAt(new Date())); - }) - .catch((reason) => { - if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason)); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - return () => { cancelled = true; }; - }, [pick, effectiveRange.start, effectiveRange.end, refreshKey]); - - const allRows = useMemo( - () => mergeHydrogenDailyRows(datasets.lingniu, datasets.external), - [datasets.external, datasets.lingniu], - ); - const vehicleRows = useMemo(() => { - if (vehicleScope === 'lingniu') return datasets.lingniu; - if (vehicleScope === 'external') return datasets.external; - return allRows; - }, [allRows, datasets.external, datasets.lingniu, vehicleScope]); - const stationOptions = useMemo(() => getHydrogenDailyStations(vehicleRows), [vehicleRows]); - - useEffect(() => { - if (scope !== 'station') return; - if (selectedStationId !== null && stationOptions.some((station) => station.id === selectedStationId)) return; - setSelectedStationId(stationOptions[0]?.id ?? null); - }, [scope, selectedStationId, stationOptions]); - - const scopedRows = useMemo(() => { - if (scope !== 'station' || selectedStationId === null) return vehicleRows; - return filterHydrogenRowsByStation(vehicleRows, selectedStationId); - }, [scope, selectedStationId, vehicleRows]); - - const summary = useMemo( - () => summarizeHydrogenRows(scopedRows), - [scopedRows], - ); - const trend = useMemo( - () => buildHydrogenDailyTrend(datasets.lingniu, datasets.external, vehicleScope, scope === 'station' ? selectedStationId : null), - [datasets.external, datasets.lingniu, scope, selectedStationId, vehicleScope], - ); - - const selectedStation = stationOptions.find((station) => station.id === selectedStationId); - - const dayCount = useMemo(() => { - const start = new Date(effectiveRange.start).getTime(); - const end = new Date(effectiveRange.end).getTime(); - return Math.max(1, Math.round(Math.abs(end - start) / (24 * 3600 * 1000)) + 1); - }, [effectiveRange.end, effectiveRange.start]); - - const lingniuSum = useMemo(() => { - return (datasets.lingniu ?? []).reduce((s, r) => s + r.totalKg, 0); - }, [datasets.lingniu]); - - const externalSum = useMemo(() => { - return (datasets.external ?? []).reduce((s, r) => s + r.totalKg, 0); - }, [datasets.external]); - - const loadDetail = async (date: string, force = false) => { - if (!force && details[date]?.data) return; - setDetails((previous) => ({ - ...previous, - [date]: { loading: true, data: previous[date]?.data ?? null, error: null }, - })); - try { - const data = await fetchHydrogenDailyDetail( - date, - vehicleScope, - scope === 'station' ? selectedStationId : null, - verifyScope, - ); - setDetails((previous) => ({ - ...previous, - [date]: { loading: false, data, error: null }, - })); - } catch (reason) { - setDetails((previous) => ({ - ...previous, - [date]: { - loading: false, - data: previous[date]?.data ?? null, - error: reason instanceof Error ? reason.message : String(reason), - }, - })); - } - }; - - const toggleRow = (date: string) => { - setExpanded((previous) => { - const next = new Set(previous); - if (next.has(date)) { - next.delete(date); - } else { - next.add(date); - void loadDetail(date); - } - return next; - }); - }; - - const handleSelectDate = (date: string) => { - setExpanded((previous) => new Set(previous).add(date)); - void loadDetail(date); - setHighlightedDate(date); - const element = document.getElementById(`hydrogen-daily-row-${date}`); - element?.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }; - - if (loading && !scopedRows) { - return ; - } - - if (error && !scopedRows) { - return ; - } - - return ( -
- { - setPick(p); - setDateRange(getQuickRange(p)); - }} - onCustomPick={() => setPick('custom')} - onDateRangeChange={(field, value) => { - setPick('custom'); - setDateRange((prev) => ({ ...prev, [field]: value })); - }} - onCustomerChange={(cust: CustomerType) => { - setVehicleScope(cust === 'all' ? 'all' : cust === 'lingniu' ? 'lingniu' : 'external'); - }} - onStationChange={scope === 'station' ? setSelectedStationId : undefined} - onVehicleScopeChange={setVehicleScope} - onRefresh={() => setRefreshKey((k) => k + 1)} - /> - - {error ? ( -
-
- - 最新数据刷新失败,正展示上一版缓存内容:{error} -
- -
- ) : null} - - {scope === 'station' && selectedStation ? ( - - ) : null} - - - - - - {scopedRows && scopedRows.length > 0 ? ( - void loadDetail(date, true)} - /> - ) : ( - - )} -
- ); -} diff --git a/src/modules/energy/HydrogenOverview.tsx b/src/modules/energy/HydrogenOverview.tsx deleted file mode 100644 index 2a41550..0000000 --- a/src/modules/energy/HydrogenOverview.tsx +++ /dev/null @@ -1,282 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { - fetchHydrogenOverview, - fetchHydrogenOverviewDetail, - type HydrogenOverviewResponse, - type HydrogenVehicleScope, - type HydrogenVerifyScope, -} from './api'; -import type { HydrogenBoardScope } from './HydrogenBoardChrome'; -import { DistributionCharts } from './hydrogen-overview/components/DistributionCharts'; -import { HydrogenOverviewSkeleton } from './hydrogen-overview/components/HydrogenOverviewSkeleton'; -import { InsightCards } from './hydrogen-overview/components/InsightCards'; -import { KpiSection } from './hydrogen-overview/components/KpiSection'; -import { MonthlyCharts } from './hydrogen-overview/components/MonthlyCharts'; -import { OverviewDrillTreeDialog } from './hydrogen-overview/components/OverviewDrillTreeDialog'; -import { OverviewHeader } from './hydrogen-overview/components/OverviewHeader'; -import { RefreshOverlay } from './hydrogen-overview/components/RefreshOverlay'; -import { CustomerSummaryTable, StationSummaryTable } from './hydrogen-overview/components/SummaryTables'; -import { - deriveOverviewMetrics, - formatYuan, - type OverviewDrillRequest, - type OverviewScope, -} from './hydrogen-overview/model'; -import type { - HydrogenOverviewDetailGroupBy, - HydrogenOverviewDetailResponse, -} from './types'; -import './styles/energy-bi-board.css'; - -export interface HydrogenOverviewProps { - scope?: OverviewScope; - selectedStationId?: number | null; - onScopeChange?: (scope: HydrogenBoardScope) => void; - onSubChange?: (sub: 'daily' | 'overview' | 'settlement') => void; - onSelectStation?: (stationId: number | null) => void; - onOpenDaily?: () => void; - onRangeTextChange?: (rangeText: string) => void; -} - -const DEFAULT_AVAILABLE_YEARS = [2026, 2025, 2024, 2023, 2022, 2021, 2020]; - -interface DrillSelection { - stationId?: number | null; - customerId?: number | null; - customerName?: string | null; - plateNo?: string | null; - region?: string | null; - month?: string | null; - date?: string | null; -} - -export default function HydrogenOverview({ - scope = 'global', - selectedStationId = null, - onScopeChange, - onSubChange, - onSelectStation, - onOpenDaily, - onRangeTextChange, -}: HydrogenOverviewProps) { - const currentYear = new Date().getFullYear(); - const [activeYear, setActiveYear] = useState(() => { - return DEFAULT_AVAILABLE_YEARS.includes(currentYear) ? currentYear : 2026; - }); - const [vehicleScope, setVehicleScope] = useState('all'); - const [verifyScope, setVerifyScope] = useState('all'); - const [internalStationId, setInternalStationId] = useState(selectedStationId); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - const [lastRefreshAt, setLastRefreshAt] = useState(0); - const [error, setError] = useState(null); - - const [drillTree, setDrillTree] = useState<{ - title: string; - selection: DrillSelection; - } | null>(null); - - const effectiveStationId = scope === 'station' ? (internalStationId ?? selectedStationId) : null; - - const load = useCallback(async (force = false) => { - if (force) setRefreshing(true); - else setLoading(true); - setError(null); - - try { - const result = await fetchHydrogenOverview({ - year: activeYear, - vehicleScope, - verifyScope, - stationId: effectiveStationId, - force, - }); - setData(result); - setLastRefreshAt(Date.now()); - const rangeEnd = result.latestLedgerTime?.slice(0, 10) ?? (activeYear === currentYear ? '至今' : `${activeYear}-12-31`); - onRangeTextChange?.(`${activeYear}-01-01 至 ${rangeEnd}`); - } catch (reason) { - setError(reason instanceof Error ? reason.message : String(reason)); - } finally { - setLoading(false); - setRefreshing(false); - } - }, [activeYear, currentYear, effectiveStationId, onRangeTextChange, vehicleScope, verifyScope]); - - useEffect(() => { - void load(false); - }, [load]); - - const handleDrillRequest = (request: OverviewDrillRequest) => { - const selection: DrillSelection = {}; - if (request.entityId) { - if (request.kind === 'station') selection.stationId = request.entityId; - if (request.kind === 'customer') selection.customerId = request.entityId; - } - if (request.kind === 'customer' && !request.entityId) selection.customerName = request.key; - if (request.kind === 'month') selection.month = request.key; - if (request.kind === 'region') selection.region = request.key; - - setDrillTree({ - title: request.label || '数据下钻穿透', - selection, - }); - }; - - const handleDrillLoad = useCallback(async ( - groupBy: HydrogenOverviewDetailGroupBy | null, - selection: DrillSelection, - scopeParam: HydrogenVehicleScope, - includeAll = false, - ): Promise => { - return fetchHydrogenOverviewDetail({ - year: activeYear, - vehicleScope: scopeParam, - verifyScope, - stationId: selection.stationId ?? effectiveStationId, - customerId: selection.customerId ?? null, - customerName: selection.customerName ?? null, - plateNo: selection.plateNo ?? null, - month: selection.month ?? null, - date: selection.date ?? null, - region: selection.region ?? null, - groupBy, - limit: includeAll ? 500 : 50, - }); - }, [activeYear, effectiveStationId, verifyScope]); - - const derived = useMemo(() => { - if (!data) return null; - return deriveOverviewMetrics(data); - }, [data]); - - if (loading && !data) { - return ; - } - - if (error && !data) { - return ( -
-
氢能总览数据加载失败
-

{error}

- -
- ); - } - - const selectedStationName = data?.stations.find((s) => s.id === effectiveStationId)?.name ?? null; - const yearProfitFmt = data ? formatYuan(data.kpi.yearProfit) : { value: '0', unit: '元' }; - const yearRevenueFmt = data ? formatYuan(data.kpi.yearRevenue) : { value: '0', unit: '元' }; - - return ( - <> - setActiveYear(y)} - onVehicleScopeChange={(vs) => setVehicleScope(vs)} - onVerifyScopeChange={setVerifyScope} - onRefresh={() => void load(true)} - /> - - {data && derived ? ( -
- - - { - setInternalStationId(stId); - onSelectStation?.(stId); - }} - onDrillRequest={handleDrillRequest} - /> - -
- - - { - setInternalStationId(stId); - onSelectStation?.(stId); - }} - scope={scope} - scopeLabel={selectedStationName} - onDrillRequest={handleDrillRequest} - /> -
- - { - setInternalStationId(stId); - onSelectStation?.(stId); - }} - scope={scope} - scopeLabel={selectedStationName} - onDrillRequest={handleDrillRequest} - /> - - -
- ) : null} - - {drillTree && ( - setDrillTree(null)} - /> - )} - - - - ); -} diff --git a/src/modules/energy/HydrogenSettlement.tsx b/src/modules/energy/HydrogenSettlement.tsx deleted file mode 100644 index 2eafa16..0000000 --- a/src/modules/energy/HydrogenSettlement.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { useEffect, useMemo, useState } from 'react'; -import { Building2, CalendarDays, Landmark, ReceiptText, ShieldCheck, WalletCards } from 'lucide-react'; -import { fetchHydrogenSettlement, type HydrogenSettlementRange, type HydrogenSettlementResponse } from './api'; -import { getQuickRange } from './hydrogen-daily/model'; -import RotatingFooterHint from '../../components/RotatingFooterHint'; -import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface'; -import { SortableColumnHeader, sortBy, toggleSort, type SortDirection } from './components/SortableColumnHeader'; - -type ViewRange = HydrogenSettlementRange | 'custom'; - -const RANGE_OPTIONS: { id: ViewRange; label: string }[] = [ - { id: 'latest', label: '最近有数据' }, - { id: 'thisWeek', label: '本周' }, - { id: 'thisMonth', label: '本月' }, - { id: 'last15', label: '近15日' }, - { id: 'custom', label: '自定义' }, -]; - -const matchModeText = { - exact: '精确匹配', - manual: '人工匹配', - group: '集团匹配', - unmatched: '待匹配', -} as const; - -function formatYuan(value: number) { - return `¥${value.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`; -} - -export default function HydrogenSettlement() { - const [range, setRange] = useState('latest'); - const [dateRange, setDateRange] = useState(() => getQuickRange('last15')); - const [data, setData] = useState(null); - const [selectedStation, setSelectedStation] = useState(''); - const [sortKey, setSortKey] = useState<'date' | 'stationName' | 'matchMode' | 'paymentCount' | 'amount'>('date'); - const [sortDirection, setSortDirection] = useState('desc'); - const [error, setError] = useState(null); - - useEffect(() => { - let cancelled = false; - setError(null); - const query = range === 'custom' - ? { range: 'custom' as const, startDate: dateRange.start, endDate: dateRange.end } - : { range }; - fetchHydrogenSettlement(query) - .then(result => { if (!cancelled) setData(result); }) - .catch(reason => { if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason)); }); - return () => { cancelled = true; }; - }, [range, dateRange.start, dateRange.end]); - - const stationOptions = useMemo(() => { - if (!data) return []; - return Array.from(new Set(data.rows.map(row => row.stationName))).sort((a, b) => a.localeCompare(b, 'zh-CN')); - }, [data]); - const rows = useMemo( - () => data?.rows.filter(row => !selectedStation || row.stationName === selectedStation) ?? [], - [data, selectedStation], - ); - const sortedRows = useMemo(() => sortBy(rows, sortKey, sortDirection, (row, key) => row[key]), [rows, sortDirection, sortKey]); - const selectedSummary = useMemo(() => ({ - amount: rows.reduce((total, row) => total + row.amount, 0), - paymentCount: rows.reduce((total, row) => total + row.paymentCount, 0), - stationDayCount: rows.length, - }), [rows]); - - useEffect(() => { - if (selectedStation && !stationOptions.includes(selectedStation)) setSelectedStation(''); - }, [selectedStation, stationOptions]); - - const setPreset = (next: ViewRange) => { - setRange(next); - if (next !== 'latest' && next !== 'custom') setDateRange(getQuickRange(next)); - }; - const changeSort = (nextKey: typeof sortKey) => { - const next = toggleSort(sortKey, sortDirection, nextKey); - setSortKey(next.key); - setSortDirection(next.direction); - }; - - return ( -
-
-

站日现结台账

-

只读展示已入库的加氢站付款流水,不提供登记或审批操作

-
- - -
- {RANGE_OPTIONS.map(option => ( - - ))} -
- {range === 'custom' ? ( -
- {(['start', 'end'] as const).map(field => ( - - ))} -
- ) : null} - {stationOptions.length > 0 ? ( - - ) : null} -
- - {error ? : null} - {!data && !error ? : null} - - {data ? ( -
- - - - -
- ) : null} - - {data && rows.length === 0 ? : null} - - {data && rows.length > 0 ? ( - -
-
-

站日现结明细

-

付款日期、匹配站点及实际付款金额

-
-
- 只读台账 -
-
-
- - - - - - - - - - - - {sortedRows.map(row => ( - - - - - - - - ))} - -
{row.date}{row.stationName}{matchModeText[row.matchMode]}{row.paymentCount}{formatYuan(row.amount)}
-
-
- ) : null} - -
- ); -} diff --git a/src/modules/energy/HydrogenStationBoard.tsx b/src/modules/energy/HydrogenStationBoard.tsx deleted file mode 100644 index 58e5943..0000000 --- a/src/modules/energy/HydrogenStationBoard.tsx +++ /dev/null @@ -1,562 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - ArrowLeft, - Calendar, - Check, - ChevronDown, - ChevronLeft, - ChevronRight, - Download, - Fuel, - RefreshCw, - Wallet, - X, -} from 'lucide-react'; -import * as XLSX from 'xlsx'; -import { fetchHydrogenStationBoard } from './api'; -import type { - HydrogenStationBoardCustomerMonth, - HydrogenStationBoardResponse, - HydrogenStationBoardStation, - HydrogenStationBoardSummaryDailyRow, -} from './types'; -import { SortableColumnHeader, sortBy, toggleSort, type SortDirection } from './components/SortableColumnHeader'; -import './styles/energy-bi-board.css'; -import './hydrogen-bi-v2/prototype-station-daily.css'; - -function formatYmd(date: Date): string { - return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; -} - -function defaultRange() { - const end = new Date(); - end.setHours(0, 0, 0, 0); - const start = new Date(end); - start.setDate(start.getDate() - 9); - return { start: formatYmd(start), end: formatYmd(end) }; -} - -function formatNumber(value: number, digits = 2): string { - return value.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits }); -} - -function regionLabel(station: HydrogenStationBoardStation): string { - const parts = [station.province, station.city].filter(part => part && part !== '未归属'); - return [...new Set(parts)].join(' · ') || '区域待补充'; -} - -function resetPageScroll() { - document.documentElement.scrollTop = 0; - document.body.scrollTop = 0; - window.scrollTo(0, 0); -} - -function onlyStationsWithHydrogenRecords(result: HydrogenStationBoardResponse): HydrogenStationBoardResponse { - const stations = result.stations.filter(station => station.recordCount > 0); - return { - ...result, - stations, - summary: { - ...result.summary, - stationCount: stations.length, - activeStationCount: stations.length, - }, - }; -} - -export default function HydrogenStationBoard({ embedded = false }: { embedded?: boolean }) { - const [dateRange, setDateRange] = useState(defaultRange); - const [selectedStationId, setSelectedStationId] = useState(null); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const load = useCallback(async (force = false) => { - setLoading(true); - // A new request invalidates the previous range immediately. Never leave - // stale figures visible while the current API request is pending or failed. - setData(null); - setError(null); - try { - const result = await fetchHydrogenStationBoard({ - startDate: dateRange.start, - endDate: dateRange.end, - stationId: selectedStationId, - force, - }); - setData(onlyStationsWithHydrogenRecords(result)); - setError(null); - } catch (reason) { - setData(null); - setError(reason instanceof Error ? reason.message : String(reason)); - } finally { - setLoading(false); - } - }, [dateRange.end, dateRange.start, selectedStationId]); - - useEffect(() => { void load(false); }, [load]); - - useEffect(() => { - // 站点列表较长,切换列表/详情时统一从页面顶部开始展示。 - resetPageScroll(); - const frame = window.requestAnimationFrame(resetPageScroll); - return () => window.cancelAnimationFrame(frame); - }, [selectedStationId]); - - const selectedStation = data?.stations.find(station => station.id === selectedStationId) ?? null; - - if (error && !data) { - return ( -
-
单站经营数据暂时不可用。
- -
- ); - } - - return ( -
- {!(selectedStation && data?.selected) ? { - setSelectedStationId(null); - window.requestAnimationFrame(resetPageScroll); - }} - onRangeChange={setDateRange} - onRefresh={() => void load(true)} - onExport={() => data && exportStationEvidence(data, selectedStation)} - /> : null} - - {data ? selectedStation && data.selected ? ( - setSelectedStationId(null)} - onRangeChange={setDateRange} - onRefresh={() => void load(true)} - onExport={() => exportStationEvidence(data, selectedStation)} - /> - ) : ( - { - setSelectedStationId(stationId); - window.requestAnimationFrame(resetPageScroll); - }} - /> - ) : ( - - )} - - {loading && data ? ( -
-
- 正在刷新单站数据 -
-
- ) : null} -
- ); -} - -function StationBoardHeader({ - embedded, - selectedStation, - range, - latestLedgerTime, - loading, - onBack, - onRangeChange, - onRefresh, - onExport, -}: { - embedded: boolean; - selectedStation: HydrogenStationBoardStation | null; - range: { start: string; end: string }; - latestLedgerTime: string | null; - loading: boolean; - onBack: () => void; - onRangeChange: (range: { start: string; end: string }) => void; - onRefresh: () => void; - onExport: () => void; -}) { - if (embedded && !selectedStation) { - return

最后更新时间 {latestLedgerTime ?? '暂无账本时间'}

; - } - return ( -
-
- {selectedStation ? ( - - ) : null} -
- {selectedStation ?

{selectedStation.name}

: null} - {selectedStation ?

{regionLabel(selectedStation)} · {range.start} 至 {range.end}

: null} -

最后更新时间 {latestLedgerTime ?? '暂无账本时间'}

-
- - - {selectedStation ? ( - - ) : null} -
-
- ); -} - -function StationRangePicker({ range, onChange }: { range: { start: string; end: string }; onChange: (range: { start: string; end: string }) => void }) { - const [open, setOpen] = useState(false); - const [activeBoundary, setActiveBoundary] = useState<'start' | 'end'>('start'); - const [draft, setDraft] = useState(range); - const initial = new Date(`${range.start}T00:00:00`); - const [displayMonth, setDisplayMonth] = useState({ year: initial.getFullYear(), month: initial.getMonth() + 1 }); - const rootRef = useRef(null); - useEffect(() => { - const closeWhenOutside = (event: MouseEvent) => { - if (rootRef.current && !rootRef.current.contains(event.target as Node)) setOpen(false); - }; - document.addEventListener('mousedown', closeWhenOutside); - return () => document.removeEventListener('mousedown', closeWhenOutside); - }, []); - const anchor = new Date(`${range.end}T00:00:00`); - const apply = (next: { start: string; end: string }, close = false) => { - const normalized = next.start <= next.end ? next : { start: next.end, end: next.start }; - setDraft(normalized); - if (close) { onChange(normalized); setOpen(false); } - }; - const today = formatYmd(anchor); - const startOfWeek = new Date(anchor); - startOfWeek.setDate(anchor.getDate() - ((anchor.getDay() + 6) % 7)); - const shortcuts = { - today: { start: today, end: today }, - week: { start: formatYmd(startOfWeek), end: formatYmd(new Date(startOfWeek.getFullYear(), startOfWeek.getMonth(), startOfWeek.getDate() + 6)) }, - month: { start: `${anchor.getFullYear()}-${String(anchor.getMonth() + 1).padStart(2, '0')}-01`, end: formatYmd(new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0)) }, - }; - const daysInMonth = new Date(displayMonth.year, displayMonth.month, 0).getDate(); - const leadingDays = new Date(displayMonth.year, displayMonth.month - 1, 1).getDay(); - const selectDay = (day: number) => { - const value = `${displayMonth.year}-${String(displayMonth.month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; - if (activeBoundary === 'start') { - const next = { start: value, end: value > draft.end ? value : draft.end }; - setDraft(next); setActiveBoundary('end'); return; - } - const next = value < draft.start ? { start: value, end: draft.start } : { start: draft.start, end: value }; - setDraft(next); setActiveBoundary('start'); - }; - return
- - {open ?
-
-
-
{displayMonth.year}年{String(displayMonth.month).padStart(2, '0')}月
-
{['日','一','二','三','四','五','六'].map(day => {day})}
{Array.from({ length: leadingDays }, (_, index) => )}{Array.from({ length: daysInMonth }, (_, index) => { const day = index + 1; const value = `${displayMonth.year}-${String(displayMonth.month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const classes = ['sd-date__day', value === draft.start || value === draft.end ? 'is-selected' : '', value >= draft.start && value <= draft.end ? 'is-in-range' : ''].filter(Boolean).join(' '); return ; })}
-
-
: null} -
; -} - -function StationOverview({ data, onSelectStation }: { data: HydrogenStationBoardResponse; onSelectStation: (id: number) => void }) { - const [displayMode, setDisplayMode] = useState<'all' | 'single'>('all'); - const [displayStationId, setDisplayStationId] = useState(data.stations[0]?.id ?? 0); - const [dailyDrill, setDailyDrill] = useState<'kg' | 'fee' | 'payment' | null>(null); - const [partnersExpanded, setPartnersExpanded] = useState(false); - const { selfManagedStations, partnerStations } = useMemo(() => { - const selfManaged = data.stations - .filter(isSelfManagedStation) - .sort((left, right) => selfManagedStationRank(left) - selfManagedStationRank(right)); - const selfManagedIds = new Set(selfManaged.map(station => station.id)); - return { - selfManagedStations: selfManaged, - partnerStations: data.stations - .filter(station => station.kg > 0 && !selfManagedIds.has(station.id)) - .sort((left, right) => right.kg - left.kg || left.name.localeCompare(right.name, 'zh-CN')), - }; - }, [data.stations]); - const visiblePartnerStations = partnersExpanded ? partnerStations : partnerStations.slice(0, 10); - const selectedStation = data.stations.filter(station => station.id === displayStationId); - // Top5 的条形、图例与钻取入口必须共用同一份按加氢量降序的结果。 - const shareStations = data.stations - .filter(station => station.kg > 0) - .sort((left, right) => right.kg - left.kg) - .slice(0, 5); - const shareTrackTotal = shareStations.reduce((sum, station) => sum + station.kg, 0); - return ( - <> -
-
加氢站{data.summary.stationCount}统计站点数 · 活跃 {data.summary.activeStationCount} 站
- setDailyDrill('kg')} /> - setDailyDrill('fee')} /> - item.paymentAmount > 0).length} 天有进账`} onClick={() => setDailyDrill('payment')} /> -
-
-

加氢量占比Top5 {data.range.start} 至 {data.range.end}

合计 {formatNumber(data.summary.totalKg)} Kg
-
{shareStations.map((station, index) => )}
-
    {shareStations.map((station, index) =>
  • )}
-
- -
-

各站概况

{displayMode === 'single' ? : null}
- {displayMode === 'all' ?
- - - {partnerStations.length > 10 ? : null} -
:
} -
- setDailyDrill(null)} - /> - - ); -} - -function isSelfManagedStation(station: HydrogenStationBoardStation): boolean { - return station.name.includes('佛山南海羚牛') || station.name.includes('东鹏大道'); -} - -function selfManagedStationRank(station: HydrogenStationBoardStation): number { - return station.name.includes('佛山南海羚牛') ? 0 : 1; -} - -function StationGroup({ title, count, stations, onSelectStation, emptyText }: { - title: string; - count: number; - stations: HydrogenStationBoardStation[]; - onSelectStation: (id: number) => void; - emptyText: string; -}) { - return
-

{title}

{count} 站
- {stations.length ?
:

{emptyText}

} -
; -} - -function StationRows({ stations, onSelectStation }: { stations: HydrogenStationBoardStation[]; onSelectStation: (id: number) => void }) { - return <>{stations.map(station => )}; -} - -function StationSummaryMetric({ label, value, unit, helper, onClick }: { label: string; value: string; unit?: string; helper: string; onClick: () => void }) { - return ; -} - -function StationSummaryDailyDialog({ - mode, - range, - rows, - onClose, -}: { - mode: 'kg' | 'fee' | 'payment' | null; - range: { start: string; end: string }; - rows: HydrogenStationBoardSummaryDailyRow[]; - onClose: () => void; -}) { - const [sortKey, setSortKey] = useState<'date' | 'recordCount' | 'kg' | 'fee' | 'paymentCount' | 'paymentAmount'>('date'); - const [sortDirection, setSortDirection] = useState('desc'); - const sortedRows = useMemo(() => sortBy(rows, sortKey, sortDirection, (row, key) => row[key]), [rows, sortDirection, sortKey]); - const changeSort = (nextKey: typeof sortKey) => { - const next = toggleSort(sortKey, sortDirection, nextKey); - setSortKey(next.key); - setSortDirection(next.direction); - }; - if (!mode) return null; - const title = mode === 'kg' - ? '统计加氢总量 · 日明细' - : mode === 'fee' - ? '统计加氢金额 · 单日数据' - : '统计现结金额 · 单日数据'; - return ( -
{ if (event.target === event.currentTarget) onClose(); }}> -
-
-
-

{title}

-

{range.start} 至 {range.end}

-
- -
-
- {mode === 'payment' ? ( - - - {sortedRows.map(row => )} -
{row.date}{row.paymentCount}{formatNumber(row.paymentAmount)}
- ) : ( - - - {sortedRows.map(row => )} -
{row.date}{row.recordCount}{formatNumber(row.kg)}{formatNumber(row.fee)}
- )} -
-
-
- ); -} - -function StationRowMetric({ label, value, unit }: { label: string; value: string; unit: string }) { - return
{label}{value}{unit}
; -} - -function MiniSpark({ values }: { values: number[] }) { - const max = Math.max(...values, 0); - return
{values.map((value, index) => 0 ? Math.max(6, Math.round((value / max) * 100)) : 0}%` }} />)}
; -} - -function StationDetail({ - station, - data, - latestLedgerTime, - loading, - onBack, - onRangeChange, - onRefresh, - onExport, -}: { - station: HydrogenStationBoardStation; - data: HydrogenStationBoardResponse; - latestLedgerTime: string | null; - loading: boolean; - onBack: () => void; - onRangeChange: (range: { start: string; end: string }) => void; - onRefresh: () => void; - onExport: () => void; -}) { - const detail = data.selected!; - const [hoveredDate, setHoveredDate] = useState(null); - const [dailySummaryExpanded, setDailySummaryExpanded] = useState(false); - const lastDaily = detail.daily.at(-1); - const visibleDailySummary = dailySummaryExpanded - ? detail.daily - : detail.daily.slice(-30); - const monthKey = data.range.end.slice(0, 7); - const monthRows = detail.customerMonths.filter(item => item.month === monthKey); - const monthKg = monthRows.reduce((sum, row) => sum + row.kg, 0); - const intervalPayments = detail.daily.reduce((sum, row) => sum + row.paymentAmount, 0); - const trendMax = Math.max(...detail.daily.map(row => row.kg), 0); - const hovered = detail.daily.find(row => row.date === hoveredDate) ?? null; - - return ( - <> -
-

{station.name}

{regionLabel(station)} · {data.range.start} 至 {data.range.end}

最后更新时间 {latestLedgerTime ?? '暂无账本时间'}

-
-
-
- - - - row.paymentAmount > 0).length}天有进账`} /> -
-
-

加氢站每日加氢量汇总({data.range.start} 至 {data.range.end})

-
- - {[...visibleDailySummary].reverse().map(row => )} - {detail.daily.length > 30 ? : null} -
日期 ↓加氢量(Kg) ↕较昨日 ↕单价 ↕成本金额(元) ↕车次 ↕
查询区间合计{formatNumber(detail.daily.reduce((sum, row) => sum + row.kg, 0))}{formatNumber(detail.daily.reduce((sum, row) => sum + row.fee, 0))}{detail.daily.reduce((sum, row) => sum + row.recordCount, 0)}
{row.date}{formatNumber(row.kg)} 0 ? 'is-stock-up' : row.changeKg < 0 ? 'is-stock-down' : 'is-stock-flat'}`}>{row.changeKg > 0 ? '+' : ''}{formatNumber(row.changeKg)}{row.changeKg !== 0 ? {row.changeKg > 0 ? '▲' : '▼'} : null}{formatNumber(row.avgPrice)}{formatNumber(row.fee)}{row.recordCount}
-
-

区间加氢量趋势

{station.name}
{hovered ?
{hovered.date}
加氢量{formatNumber(hovered.kg)} Kg
{hovered.recordCount}车次 · ¥{formatNumber(hovered.fee)}
: null}
{detail.daily.map(row =>
setHoveredDate(row.date)} onMouseLeave={() => setHoveredDate(null)}>{formatNumber(row.kg, 0)}
0 ? Math.max(3, Math.round((row.kg / trendMax) * 100)) : 0}%` }} />
{row.date.slice(5)}
)}
- - - - - - ); -} - -function DetailMetric({ label, value, unit, helper }: { label: string; value: string; unit: string; helper: string }) { - return
{label}{value}{unit}{helper}
; -} - -function CustomerMonthMatrix({ title, valueKey, rows }: { title: string; valueKey: 'kg' | 'fee'; rows: HydrogenStationBoardCustomerMonth[] }) { - const months = useMemo(() => [...new Set(rows.map(row => row.month))].sort(), [rows]); - const customers = useMemo(() => [...new Set(rows.map(row => row.customerName))].sort((left, right) => left.localeCompare(right, 'zh-CN')), [rows]); - const [selectedCustomers, setSelectedCustomers] = useState([]); - const [expanded, setExpanded] = useState(false); - const values = useMemo(() => new Map(rows.map(row => [`${row.customerName}\u0000${row.month}`, row[valueKey]])), [rows, valueKey]); - const totals = useMemo(() => new Map(months.map(month => [month, rows.filter(row => row.month === month).reduce((sum,row)=>sum+row[valueKey],0)])), [months, rows, valueKey]); - const visibleCustomers = selectedCustomers.length === 0 ? customers : customers.filter(customer => selectedCustomers.includes(customer)); - const shownCustomers = expanded ? visibleCustomers : visibleCustomers.slice(0, 10); - const trendClass = (current: number, previous: number | undefined) => previous == null || current === previous ? '' : current > previous ? 'is-stock-up' : 'is-stock-down'; - const trendMark = (current: number, previous: number | undefined) => previous == null || current === previous ? '' : current > previous ? '▲' : '▼'; - return ( -
-

{title}

-
{months.map(month => )} - {months.map((month, index) => { const current = totals.get(month) ?? 0; const previous = index > 0 ? totals.get(months[index - 1]) : undefined; return ; })} - {shownCustomers.map(customer => {months.map((month, index) => { const current = values.get(`${customer}\u0000${month}`) ?? 0; const previous = index > 0 ? values.get(`${customer}\u0000${months[index - 1]}`) : undefined; return ; })})} -
客户{month.replace('-', '年')}月
合计{formatNumber(current)}{trendMark(current, previous)}
{customer}{formatNumber(current)}{trendMark(current, previous)}
{visibleCustomers.length > 10 ? : null} -
- ); -} - -function StationCashPanels() { - return
-
-

客户氢费收支汇总

-
客户充值/现金结算扣预付现结余额备注
数据写入中,敬请期待。
-
-
-

氢费充值/现结进账明细

数据写入中
-
充值日期客户付款方式金额(元)
数据写入中,敬请期待。
-
-
; -} - -function CustomerMultiSelect({ options, value, onChange }: { options: string[]; value: string[]; onChange: (value: string[]) => void }) { - const [open, setOpen] = useState(false); - const [search, setSearch] = useState(''); - const rootRef = useRef(null); - const allSelected = value.length === 0 || value.length === options.length; - const selectedLabel = allSelected ? '全部客户' : value.length <= 2 ? value.join('、') : `已选 ${value.length} 家`; - const visibleOptions = search.trim() ? options.filter(option => option.includes(search.trim())) : options; - useEffect(() => { - if (!open) return; - const close = (event: MouseEvent) => { if (!rootRef.current?.contains(event.target as Node)) setOpen(false); }; - document.addEventListener('mousedown', close); - return () => document.removeEventListener('mousedown', close); - }, [open]); - const toggle = (customer: string) => { - if (allSelected) { onChange([customer]); return; } - if (value.includes(customer)) { const next = value.filter(item => item !== customer); onChange(next.length === 0 ? [] : next); return; } - const next = [...value, customer]; - onChange(next.length === options.length ? [] : next); - }; - return
{open ?
setSearch(event.target.value)} placeholder="搜索客户" aria-label="搜索客户" />
    {visibleOptions.length === 0 ?
  • 无匹配客户
  • : visibleOptions.map(option => { const on = allSelected || value.includes(option); return
  • ; })}
: null}
; -} - -function exportStationEvidence(data: HydrogenStationBoardResponse, station: HydrogenStationBoardStation | null) { - if (!station || !data.selected) return; - const workbook = XLSX.utils.book_new(); - const daily = [['日期','加氢量(Kg)','较昨日(Kg)','平均单价','加氢金额(元)','车次','现结金额(元)','现结笔数'], ...data.selected.daily.map(row => [row.date,row.kg,row.changeKg,row.avgPrice,row.fee,row.recordCount,row.paymentAmount,row.paymentCount])]; - const customers = [['月份','客户','加氢量(Kg)','加氢费(元)','车次'], ...data.selected.customerMonths.map(row => [row.month,row.customerName,row.kg,row.fee,row.recordCount])]; - XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(daily), '每日汇总'); - XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(customers), '客户月汇总'); - XLSX.writeFile(workbook, `${station.name}_${data.range.start}_${data.range.end}_取证.xlsx`); -} - -function StationBoardSkeleton() { - return
; -} diff --git a/src/modules/energy/HydrogenView.tsx b/src/modules/energy/HydrogenView.tsx deleted file mode 100644 index 2767c4a..0000000 --- a/src/modules/energy/HydrogenView.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import HydrogenOverview from './HydrogenOverview'; -import HydrogenDaily from './HydrogenDaily'; -import HydrogenSettlement from './HydrogenSettlement'; -import type { HydrogenBoardScope } from './HydrogenBoardChrome'; - -export type HydrogenSubTab = 'daily' | 'overview' | 'settlement'; - -interface Props { - sub: HydrogenSubTab; - onSubChange?: (sub: HydrogenSubTab) => void; - onScopeChange?: (scope: HydrogenBoardScope) => void; - onDailyRangeTextChange?: (rangeText: string) => void; - onOverviewRangeTextChange?: (rangeText: string) => void; -} - -export default function HydrogenView({ - sub, - onSubChange, - onScopeChange, - onDailyRangeTextChange, - onOverviewRangeTextChange, -}: Props) { - if (sub === 'overview') { - return ( - - ); - } - if (sub === 'settlement') return ; - return ; -} diff --git a/src/modules/energy/components/SortableColumnHeader.tsx b/src/modules/energy/components/SortableColumnHeader.tsx deleted file mode 100644 index f66be14..0000000 --- a/src/modules/energy/components/SortableColumnHeader.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { ArrowDown, ArrowDownUp, ArrowUp } from 'lucide-react'; - -export type SortDirection = 'asc' | 'desc'; - -export function SortableColumnHeader({ - label, - sortKey, - activeSortKey, - sortDirection, - onSort, - align = 'left', - className = '', -}: { - label: string; - sortKey: Key; - activeSortKey: Key; - sortDirection: SortDirection; - onSort: (sortKey: Key) => void; - align?: 'left' | 'right' | 'center'; - className?: string; -}) { - const active = activeSortKey === sortKey; - const Icon = active ? (sortDirection === 'asc' ? ArrowUp : ArrowDown) : ArrowDownUp; - const order = active ? (sortDirection === 'asc' ? '升序' : '降序') : '未排序'; - const justify = align === 'right' ? 'justify-end' : align === 'center' ? 'justify-center' : 'justify-start'; - - return ( - - ); -} - -export function toggleSort( - currentKey: Key, - currentDirection: SortDirection, - nextKey: Key, -): { key: Key; direction: SortDirection } { - return nextKey === currentKey - ? { key: currentKey, direction: currentDirection === 'asc' ? 'desc' : 'asc' } - : { key: nextKey, direction: 'desc' }; -} - -export function sortBy( - rows: Row[], - sortKey: Key, - sortDirection: SortDirection, - valueOf: (row: Row, key: Key) => string | number | null | undefined, -): Row[] { - const multiplier = sortDirection === 'asc' ? 1 : -1; - // Keep the caller's row order immutable while remaining compatible with the - // ES2022 target used by this dashboard (Array.prototype.toSorted is ES2023). - return [...rows].sort((left, right) => { - const leftValue = valueOf(left, sortKey) ?? ''; - const rightValue = valueOf(right, sortKey) ?? ''; - if (typeof leftValue === 'number' && typeof rightValue === 'number') return (leftValue - rightValue) * multiplier; - return String(leftValue).localeCompare(String(rightValue), 'zh-CN', { numeric: true }) * multiplier; - }); -} diff --git a/src/modules/energy/daily-range/DailyRangeControls.tsx b/src/modules/energy/daily-range/DailyRangeControls.tsx index 4434651..73392ab 100644 --- a/src/modules/energy/daily-range/DailyRangeControls.tsx +++ b/src/modules/energy/daily-range/DailyRangeControls.tsx @@ -1,6 +1,5 @@ import { Calendar, Fuel, RefreshCw, Truck } from 'lucide-react'; import type { CustomerType, DateQuickPick } from '../types'; -import type { HydrogenDailyVehicleScope } from '../hydrogen-daily/model'; import { QUICK_PICK_OPTIONS, type RangeMode } from './model'; interface DailyRangeControlsProps { @@ -9,7 +8,6 @@ interface DailyRangeControlsProps { customer: CustomerType; stations?: { id: number; name: string }[]; selectedStationId?: number | null; - vehicleScope?: HydrogenDailyVehicleScope; updatedAt?: string | null; loading?: boolean; onQuickPick: (pick: DateQuickPick) => void; @@ -17,7 +15,6 @@ interface DailyRangeControlsProps { onDateRangeChange: (field: 'start' | 'end', value: string) => void; onCustomerChange: (customer: CustomerType) => void; onStationChange?: (stationId: number | null) => void; - onVehicleScopeChange?: (scope: HydrogenDailyVehicleScope) => void; onRefresh?: () => void; } @@ -27,7 +24,6 @@ export default function DailyRangeControls({ customer, stations = [], selectedStationId = null, - vehicleScope, updatedAt, loading = false, onQuickPick, @@ -35,7 +31,6 @@ export default function DailyRangeControls({ onDateRangeChange, onCustomerChange, onStationChange, - onVehicleScopeChange, onRefresh, }: DailyRangeControlsProps) { return ( @@ -92,47 +87,23 @@ export default function DailyRangeControls({
- {onVehicleScopeChange && vehicleScope ? ( -
- {([ - ['all', '全部车辆'], - ['lingniu', '仅羚牛车辆'], - ['external', '仅外部车辆'], - ] as const).map(([id, label]) => ( - - ))} -
- ) : ( -
- {(['lingniu', 'external'] as const).map(option => ( - - ))} -
- )} +
+ {(['lingniu', 'external'] as const).map(option => ( + + ))} +
{updatedAt ? {updatedAt} : null} diff --git a/src/modules/energy/hydrogen-bi-v2/EnergyOperationsBoard.tsx b/src/modules/energy/hydrogen-bi-v2/EnergyOperationsBoard.tsx deleted file mode 100644 index 4a17d86..0000000 --- a/src/modules/energy/hydrogen-bi-v2/EnergyOperationsBoard.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { Calendar, ChevronRight, Fuel, TrendingUp, Truck, Wallet, X, Zap } from "lucide-react"; -import { fetchH2BiDaily, fetchH2BiDrill, fetchH2BiMeta, fetchH2BiOverview } from "./api"; -import { downloadExcelAoa } from "./prototype-download"; -import { finiteNumber, formatNumber as number, formatScaled } from "./display-format"; -import type { H2BiDailyResponse, H2BiDrillResponse, H2BiMetaResponse, H2BiOverviewResponse, H2BiQuery, H2BiVehicleScope, H2BiVerifyScope } from "./types"; -import "./energy-operations-board.css"; - -const tons = (kg: unknown) => formatScaled(kg, 1000); -const wan = (yuan: unknown) => formatScaled(yuan, 10000); -const stamp = (value: string | null) => value ? value.replace("T", " ").slice(0, 19) : "—"; - -export const monthlyChange = (monthly: H2BiOverviewResponse["monthly"]) => { - const valid = monthly - .filter((item) => finiteNumber(item.totalKg) !== null) - .sort((a, b) => a.month.localeCompare(b.month)) - .slice(-2); - const previous = finiteNumber(valid[0]?.totalKg); - const current = finiteNumber(valid[1]?.totalKg); - if (valid.length < 2 || previous === null || current === null || previous === 0) return { value: "—", detail: "暂不可用" }; - const change = (current - previous) / previous * 100; - return { - value: `${change > 0 ? "+" : ""}${number(change, 1)}%`, - detail: `${Number(valid[1].month.slice(-2))}月较${Number(valid[0].month.slice(-2))}月`, - }; -}; - -type View = "overview" | "daily"; -type Scope = "global" | "station"; -type DrillLevel = "station" | "customer" | "vehicle" | "record"; -type Drill = { title: string; level: DrillLevel; stationId?: string; stationName?: string; customerId?: number; customerName?: string; plateNo?: string } | null; - -export default function EnergyOperationsBoard() { - const [meta, setMeta] = useState(null); - const [overview, setOverview] = useState(null); - const [daily, setDaily] = useState(null); - const [scope, setScope] = useState("global"); - const [view, setView] = useState("overview"); - const [year, setYear] = useState(new Date().getFullYear()); - const [stationId, setStationId] = useState(""); - const [vehicleScope, setVehicleScope] = useState("all"); - const [verifyScope, setVerifyScope] = useState("all"); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(""); - const [refreshKey, setRefreshKey] = useState(0); - const [drill, setDrill] = useState(null); - const [drillData, setDrillData] = useState(null); - - useEffect(() => { - fetchH2BiMeta().then((result) => { - setMeta(result); - if (result.years.length && !result.years.some((item) => item.value === year)) setYear(result.years[0].value); - }).catch((reason: unknown) => setError(reason instanceof Error ? reason.message : "筛选项加载失败")); - }, []); - - const query = useMemo(() => ({ - year, - stationId: scope === "station" && stationId ? stationId : null, - vehicleScope, - verifyScope, - }), [scope, stationId, vehicleScope, verifyScope, year]); - - useEffect(() => { - let active = true; - setLoading(true); - setError(""); - Promise.all([fetchH2BiOverview(query), fetchH2BiDaily(query)]) - .then(([nextOverview, nextDaily]) => { - if (!active) return; - setOverview(nextOverview); - setDaily(nextDaily); - }) - .catch((reason: unknown) => active && setError(reason instanceof Error ? reason.message : "能源数据加载失败")) - .finally(() => active && setLoading(false)); - return () => { active = false; }; - }, [query, refreshKey]); - - useEffect(() => { - if (!drill) { setDrillData(null); return; } - fetchH2BiDrill({ ...query, stationId: drill.stationId ?? query.stationId, customerId: drill.customerId, plateNo: drill.plateNo, groupBy: drill.level, page: 1, pageSize: 100 }) - .then(setDrillData) - .catch(() => setDrillData(null)); - }, [drill, query]); - - const kpi = overview?.kpis; - const kpis = [ - { label: "累计加氢量", value: tons(kpi?.totalKg), unit: "T", icon: Fuel, sub: [["我司承担", `${tons(kpi?.companyBearingKg)} T`], ["客户承担", `${tons(kpi?.customerBearingKg)} T`], ["其他", `${tons(kpi?.otherBearingKg)} T`]], drill: { title: "累计加氢量", level: "station" as const }, featured: true }, - { label: "累计加氢费", value: wan(kpi?.totalCost), unit: "万", prefix: "¥", icon: Wallet, sub: [["我司承担", `¥${wan(kpi?.companyCost)} 万`], ["客户承担", `¥${wan(kpi?.customerCost)} 万`], ["其他", `¥${wan(kpi?.otherCost)} 万`]], drill: { title: "累计加氢费", level: "station" as const } }, - { label: "加氢利润", value: wan(kpi?.customerGrossProfit), unit: "万", prefix: "¥", icon: TrendingUp, sub: `对客 ¥${wan(kpi?.customerRevenue)}万 · 成本 ¥${wan(kpi?.customerCost)}万`, drill: { title: "加氢利润", level: "station" as const } }, - { label: "本月加氢量", value: tons(kpi?.monthKg), unit: "T", icon: Truck, sub: `加氢费 ¥${wan(kpi?.monthCost)} 万 · 占累计 ${number(kpi?.monthShareOfRange)}%`, drill: { title: "本月加氢量", level: "station" as const }, featured: true }, - { label: "今日加氢量", value: number(kpi?.todayKg), unit: "Kg", icon: Zap, sub: `加氢费 ¥${number(kpi?.todayCost)} · 占本月 ${number(kpi?.todayShareOfMonth)}%`, drill: { title: "今日加氢量", level: "station" as const } }, - ]; - const displayMonthly = useMemo(() => { - const source = new Map((overview?.monthly ?? []).map((item) => [item.month, item])); - const finalMonth = overview?.range.endDate?.startsWith(String(year)) - ? Number(overview.range.endDate.slice(5, 7)) - : 12; - return Array.from({ length: Math.max(finalMonth, 1) }, (_, index) => { - const month = `${year}-${String(index + 1).padStart(2, "0")}`; - const item = source.get(month); - return { - month, - totalKg: finiteNumber(item?.totalKg) ?? 0, - lingniuKg: finiteNumber(item?.lingniuKg) ?? 0, - externalKg: finiteNumber(item?.externalKg) ?? 0, - customerRevenue: finiteNumber(item?.customerRevenue) ?? 0, - cost: finiteNumber(item?.cost) ?? 0, - }; - }); - }, [overview, year]); - const maxMonth = Math.max(...displayMonthly.map((item) => item.totalKg), 1); - const maxDay = Math.max(...(daily?.trend.map((item) => finiteNumber(item.kg) ?? 0) ?? []), 1); - const topTotal = overview?.stations.reduce((sum, item) => sum + (finiteNumber(item.kg) ?? 0), 0) ?? 0; - const topFive = overview?.stations.slice().sort((a, b) => b.kg - a.kg).slice(0, 5) ?? []; - const topShare = topTotal ? number(topFive.reduce((sum, item) => sum + item.kg, 0) / topTotal * 100, 1) : "—"; - const safeTotalKg = finiteNumber(kpi?.totalKg); - const safeProfit = finiteNumber(kpi?.customerGrossProfit); - const unitProfit = safeTotalKg && safeProfit !== null ? number(safeProfit / safeTotalKg) : "—"; - const maxFinance = Math.max(...displayMonthly.flatMap((item) => [item.customerRevenue, item.cost]), 1); - const regionTotal = overview?.regions.reduce((sum, item) => sum + (finiteNumber(item.kg) ?? 0), 0) ?? 0; - const bearerTotal = finiteNumber(kpi?.totalKg) ?? 0; - const bearerPct = (value: unknown) => bearerTotal ? (finiteNumber(value) ?? 0) / bearerTotal * 100 : 0; - const monthChange = monthlyChange(overview?.monthly ?? []); - - const exportOverview = () => overview && downloadExcelAoa([ - ["加氢站", "加氢量(Kg)", "成本(元)", "对客金额(元)", "流水笔数"], - ...overview.stations.map((row) => [row.name, row.kg, row.cost, row.customerRevenue, row.recordCount]), - ], `氢能经营看板_${year}.xlsx`, "氢能经营看板"); - - return ( -
-
-

氢能经营看板

实时运营

统计时间范围:{overview?.range.startDate ?? "—"} 至 {overview?.range.endDate ?? "—"}

-
-
- - -
-
-
-
- -
-
- -
- {scope === "station" && } -
-
- -
-
- - {error &&
{error}
} - {loading &&
正在加载真实能源数据…
} - - {!error && !loading && view === "overview" && <> -

累计经营概览

{year} 年累计
我司{tons(kpi?.companyBearingKg)}T{number(bearerPct(kpi?.companyBearingKg),1)}%客户{tons(kpi?.customerBearingKg)}T{number(bearerPct(kpi?.customerBearingKg),1)}%其他{tons(kpi?.otherBearingKg)}T{number(bearerPct(kpi?.otherBearingKg),1)}%
- -
-
{kpis.map(({ icon: Icon, ...item }) => )}
-
经营诊断
月度环比{monthChange.value}{monthChange.detail}
单公斤毛利{unitProfit === "—" ? "—" : `¥${unitProfit}/kg`}{unitProfit === "—" ? "暂不可用" : "按累计加氢量计算"}
头部站点占比{topShare === "—" ? "—" : `${topShare}%`}{topShare === "—" ? "暂不可用" : "前5站占总量"}
待核对订单暂不可用
-
经营诊断 展开查看
月度环比{monthChange.value}{monthChange.detail}
单公斤毛利{unitProfit === "—" ? "—" : `¥${unitProfit}/kg`}{unitProfit === "—" ? "暂不可用" : "按累计加氢量计算"}
头部站点占比{topShare === "—" ? "—" : `${topShare}%`}{topShare === "—" ? "暂不可用" : "前5站占总量"}
待核对订单暂不可用
-
-

{year} 年月度加氢量

羚牛车辆 外部车辆 统计范围:{overview?.range.startDate} 至 {overview?.range.endDate} · 单位 Kg
{displayMonthly.map((item) =>
{number(item.totalKg / 1000, 1)}k{Number(item.month.slice(-2))}月
)}
-

{year} 年月度收支对比

客户收入 成本支出 统计范围:{overview?.range.startDate} 至 {overview?.range.endDate} · 单位 元
{displayMonthly.map((item) =>
{Number(item.month.slice(-2))}月
)}
-

加氢站加氢量 Top5

    {topFive.map((item, index) =>
  1. {index + 1}{item.name}{number(item.kg, 0)}
  2. )}
-

各区域加氢占比

合计 {tons(regionTotal)} T
{overview?.regions.map((item,index)=>
{index+1}{item.region || "未归属"}{number(finiteNumber(item.share) ?? (regionTotal ? (finiteNumber(item.kg) ?? 0)/regionTotal*100 : null),1)}%
)}{!overview?.regions.length&&

暂无区域数据

}
-
- } - - {!error && !loading && view === "daily" &&

每日加氢趋势

{daily?.range.startDate ?? "—"} 至 {daily?.range.endDate ?? "—"}
{daily?.trend.map((item) =>
{number(item.kg, 0)}{item.date.slice(5)}
)}
{daily?.days.map((item) => )}
} - - {drill &&

{drill.title}

站点 → 客户 → 车辆 → 订单

{!drillData ?
正在加载明细…
: drill.level === "record" ? {drillData.records.map((row, index) => )}{drillData.records.length === 0 && }
订单加氢量成本状态
{String(row.orderNo ?? row.id ?? "—")}{number(row.kg)} Kg¥{number(row.cost)}{String(row.verifyStatus ?? row.status ?? "—")}
暂无可用订单
: {drillData.groups.map((row) => setDrill(drill.level === "station" ? { ...drill, level: "customer", stationId: row.id, stationName: row.name } : drill.level === "customer" ? { ...drill, level: "vehicle", customerId: Number(row.id), customerName: row.name } : { ...drill, level: "record", plateNo: row.name })}>)}{drillData.groups.length === 0 && }
{drill.level === "station" ? "站点" : drill.level === "customer" ? "客户" : "车辆"}加氢量成本流水
{row.name}{number(row.kg)} Kg¥{number(row.cost)}{number(row.recordCount, 0)}
暂无可用数据
}
} -
- ); -} diff --git a/src/modules/energy/hydrogen-bi-v2/HydrogenBiV2App.tsx b/src/modules/energy/hydrogen-bi-v2/HydrogenBiV2App.tsx deleted file mode 100644 index b0ceea6..0000000 --- a/src/modules/energy/hydrogen-bi-v2/HydrogenBiV2App.tsx +++ /dev/null @@ -1,1649 +0,0 @@ -import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import { - Activity, - CalendarDays, - ChevronDown, - ChevronLeft, - Download, - Fuel, - RefreshCw, - Search, - Shield, - TrendingDown, - TrendingUp, - Truck, - Wallet, - X, - Zap, -} from "lucide-react"; -import { - Bar, - BarChart, - CartesianGrid, - Legend, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from "recharts"; -import { - fetchH2BiDaily, - fetchH2BiDrill, - fetchH2BiMeta, - fetchH2BiOverview, -} from "./api"; -import type { - H2BiDailyResponse, - H2BiDrillMetric, - H2BiDrillRecord, - H2BiDrillResponse, - H2BiKpis, - H2BiMetaResponse, - H2BiOverviewResponse, - H2BiQuery, - H2BiScope, - H2BiStationRow, - H2BiVerifyScope, - H2BiVehicleScope, - H2BiView, -} from "./types"; -import "../styles/energy-bi-board.css"; - -const now = new Date(); -const isoDate = (date: Date) => date.toISOString().slice(0, 10); -const defaultEndDate = isoDate(now); -const defaultStart = new Date(now); -defaultStart.setDate(defaultStart.getDate() - 14); -const defaultStartDate = isoDate(defaultStart); - -function number(value: number | null | undefined, digits = 0) { - return Number(value ?? 0).toLocaleString("zh-CN", { - minimumFractionDigits: digits, - maximumFractionDigits: digits, - }); -} - -function kg(value: number | null | undefined) { - return `${number(value, 2)} Kg`; -} - -function yuan(value: number | null | undefined) { - return `¥${number(value, 2)}`; -} - -function toT(value: number | null | undefined) { - return number((value ?? 0) / 1000, 2); -} - -function toWan(value: number | null | undefined) { - return number((value ?? 0) / 10000, 2); -} - -function rangeText(range?: { - startDate: string | null; - endDate: string | null; -}) { - if (!range) return "加载中…"; - return ( - [range.startDate, range.endDate].filter(Boolean).join(" 至 ") || - "暂无时间范围" - ); -} - -function apiError(error: unknown) { - return error instanceof Error ? error.message : "数据加载失败,请刷新后重试"; -} - -function emptyKpis(): H2BiKpis { - return { - totalKg: 0, - totalCost: 0, - customerBearingKg: 0, - companyBearingKg: 0, - otherBearingKg: 0, - customerRevenue: 0, - customerCost: 0, - companyCost: 0, - otherCost: 0, - totalRevenue: 0, - customerGrossProfit: 0, - monthKg: 0, - monthCost: 0, - todayKg: 0, - todayCost: 0, - monthShareOfRange: 0, - todayShareOfMonth: 0, - recordCount: 0, - stationCount: 0, - }; -} - -interface DataState { - data: T | null; - error: string | null; - loading: boolean; -} - -function useRemoteData(key: string, load: () => Promise) { - const [state, setState] = useState>({ - data: null, - error: null, - loading: true, - }); - useEffect(() => { - let active = true; - setState((previous) => ({ ...previous, error: null, loading: true })); - void load() - .then((data) => active && setState({ data, error: null, loading: false })) - .catch( - (error: unknown) => - active && - setState((previous) => ({ - ...previous, - error: apiError(error), - loading: false, - })), - ); - return () => { - active = false; - }; - }, [key]); // caller supplies the query-derived key deliberately - return state; -} - -function KpiCard({ - label, - value, - prefix, - unit, - left, - right, - icon, - tone, - onClick, -}: { - label: string; - value: string; - prefix?: string; - unit: string; - left: string; - right: string; - icon: ReactNode; - tone: "blue" | "green" | "amber" | "purple" | "cyan"; - onClick: () => void; -}) { - return ( - - ); -} - -function PrototypeYearSelect({ - value, - years, - onChange, -}: { - value: number; - years: number[]; - onChange: (year: number) => void; -}) { - const [open, setOpen] = useState(false); - const ref = useRef(null); - useEffect(() => { - const close = (event: MouseEvent) => { - if (ref.current && !ref.current.contains(event.target as Node)) - setOpen(false); - }; - if (open) document.addEventListener("mousedown", close); - return () => document.removeEventListener("mousedown", close); - }, [open]); - return ( -
- - {open ? ( -
-
切换数据年份
-
- {years.map((year) => ( - - ))} -
-
- ) : null} -
- ); -} - -function EmptyTable({ children }: { children: ReactNode }) { - return ( -
-
{children}
-
- ); -} - -function TableCard({ - title, - children, - hint, -}: { - title: string; - children: ReactNode; - hint?: string; -}) { - return ( -
-
-

{title}

- {hint ? {hint} : null} -
-
{children}
-
- ); -} - -function OverviewRankings({ - overview, - onDrill, -}: { - overview: H2BiOverviewResponse; - onDrill: ( - metric: H2BiDrillMetric, - stationId?: string | number | null, - ) => void; -}) { - const [granularity, setGranularity] = useState<"province" | "city">("city"); - const total = overview.kpis.totalKg || 1; - const source = - granularity === "city" - ? overview.stations.reduce>( - (acc, station) => ({ - ...acc, - [station.city || "未归属区域"]: - (acc[station.city || "未归属区域"] || 0) + station.kg, - }), - {}, - ) - : overview.stations.reduce>( - (acc, station) => ({ - ...acc, - [station.province || "未归属"]: - (acc[station.province || "未归属"] || 0) + station.kg, - }), - {}, - ); - const colors = [ - "#0284c7", - "#38bdf8", - "#10b981", - "#f59e0b", - "#8b5cf6", - "#ec4899", - "#06b6d4", - "#84cc16", - "#94a3b8", - ]; - const regions = Object.entries(source) - .sort((a, b) => b[1] - a[1]) - .slice(0, 8) - .map(([region, kg], index) => ({ region, kg, color: colors[index] })); - const rest = total - regions.reduce((sum, region) => sum + region.kg, 0); - if (rest > 0) - regions.push({ - region: `其他${granularity === "city" ? "城市" : "省份"}`, - kg: rest, - color: "#94a3b8", - }); - let offset = 0; - return ( -
-
-
-
加氢站加氢量 Top5
-
- - - 内部客户 - - - - 外部客户 - -
-
-
- {overview.topStations.map((station, index) => { - const ratio = station.kg - ? (station.lingniuKg / station.kg) * 100 - : 0; - return ( - - ); - })} -
-
-
-
-
各区域加氢占比
-
- - -
-
-
-
- - - {regions.map((region) => { - const dash = (region.kg / total) * 238.76; - const current = offset; - offset += dash; - return ( - - ); - })} - -
-
年合计
-
{toT(total)}T
-
-
-
- {regions.map((region) => ( - - ))} -
-
-
-
- ); -} - -function MonthlyRevenueComparison({ - overview, - onDrill, -}: { - overview: H2BiOverviewResponse; - onDrill: (metric: H2BiDrillMetric) => void; -}) { - const max = Math.max( - ...overview.monthly.flatMap((row) => [ - row.customerRevenue, - row.customerCost, - ]), - 1, - ); - const year = overview.range.startDate?.slice(0, 4) || "当前"; - return ( -
-
-
-
{year} 年月度收支对比
-
- - - 对客金额 - - - - 客户承担成本金额 - - - 统计范围:{rangeText(overview.range)} · 单位 元 - -
-
-
- {overview.monthly.map((row) => ( -
-
-
-
{row.month}
-
- ))} -
-
-
- ); -} - -function OverviewTables({ - overview, - onDrill, -}: { - overview: H2BiOverviewResponse; - onDrill: ( - metric: H2BiDrillMetric, - stationId?: string | number | null, - ) => void; -}) { - const [province, setProvince] = useState("全国"); - const provinces = [ - "全国", - ...Array.from( - new Set( - overview.stations - .map((station) => station.province) - .filter((value): value is string => Boolean(value)), - ), - ), - ]; - const stations = - province === "全国" - ? overview.stations - : overview.stations.filter((station) => station.province === province); - const totalKg = overview.kpis.totalKg || 1; - const totalRevenue = overview.kpis.customerRevenue || 1; - return ( - <> -
-
-
-
加氢站加氢汇总
-
- {provinces.map((item) => ( - - ))} -
-
-
- 统计范围:{rangeText(overview.range)} · 共 {stations.length} 站 -
-
-
- - - - - - - - - - - - - - {stations.map((row, index) => ( - onDrill("station", row.id)} - style={{ cursor: "pointer" }} - > - - - - - - - - - ))} - -
#加氢站(点击钻取)所属省份加氢量占比对客金额收入占比
{index + 1} - {row.name}{" "} - 钻取 › - - - {row.province || "未归属"} - {toT(row.kg)} T -
- - - - {number((row.kg / totalKg) * 100, 1)}% -
-
¥{toWan(row.customerRevenue)} 万元 -
- - - - {number((row.customerRevenue / totalRevenue) * 100, 1)}% -
-
-
-
-
-
-
客户账单汇总
-
- 统计范围:{rangeText(overview.range)} · Top{" "} - {overview.customers.length} -
-
-
- - - - - - - - - - - - {overview.customers.map((row, index) => ( - onDrill("customer")} - style={{ cursor: "pointer" }} - > - - - - - - - ))} - -
#客户(点击钻取)加氢量客户承担成本对客金额
{index + 1} - {row.name}{" "} - 钻取 › - {toT(row.kg)} T¥{toWan(row.customerCost)} 万元¥{toWan(row.customerRevenue)} 万元
-
-
- - ); -} - -function OverviewPage({ - overview, - onDrill, -}: { - overview: H2BiOverviewResponse; - onDrill: ( - metric: H2BiDrillMetric, - stationId?: string | number | null, - ) => void; -}) { - const kpis = overview.kpis || emptyKpis(); - const firstStation = overview.topStations[0] as H2BiStationRow | undefined; - const top5Share = overview.topStations.reduce( - (sum, station) => sum + station.share, - 0, - ); - const profitRate = kpis.customerRevenue - ? (kpis.customerGrossProfit / kpis.customerRevenue) * 100 - : 0; - return ( - <> -
-
- sum + row.lingniuKg, 0))} T`} - right={`外部 ${toT(overview.monthly.reduce((sum, row) => sum + row.externalKg, 0))} T`} - icon={} - tone="blue" - onClick={() => onDrill("totalKg")} - /> - } - tone="blue" - onClick={() => onDrill("totalCost")} - /> - } - tone="green" - onClick={() => onDrill("customerGrossProfit")} - /> - } - tone="amber" - onClick={() => onDrill("monthKg")} - /> - } - tone="purple" - onClick={() => onDrill("todayKg")} - /> -
-
-
-
- -
-
-
月度加氢异常波动
-
- 占年 {number(kpis.monthShareOfRange, 2)}% -
-
- 当月 {toT(kpis.monthKg)} T ·{" "} - {overview.range.endDate?.slice(0, 7) || "当前月"} 当前统计口径 -
-
-
- -
-
- -
-
-
加氢利润率
-
= 0 ? "is-pos" : "is-neg"}`} - > - {number(profitRate, 2)}% -
-
- 加氢利润 ¥{toWan(kpis.customerGrossProfit)} 万 · 对客金额 ¥ - {toWan(kpis.customerRevenue)} 万 -
-
-
-
-
-
-
-
-
- {overview.range.startDate?.slice(0, 4) || "当前"} 年月度加氢量 -
-
- - - 内部客户 - - - - 外部客户 - - - 统计范围:{rangeText(overview.range)} · 单位 Kg - -
-
-
- {overview.monthly.length === 0 ? ( - 当前筛选暂无月度趋势数据 - ) : ( - - - - - number(Number(v))} - /> - [ - kg(Number(value)), - name === "lingniuKg" ? "内部客户" : "外部客户", - ]} - /> - - v === "lingniuKg" ? "内部客户" : "外部客户" - } - /> - - - - - )} -
-
-
- - - - - ); -} - -function DailyPage({ - daily, - onDrill, -}: { - daily: H2BiDailyResponse; - onDrill: (metric: H2BiDrillMetric) => void; -}) { - const kpis = daily.kpis; - return ( -
-
- - - - -
-
-
-
- 每日加氢量 - - (点击柱体下锚定位到对应日期明细) - -
-
-
- - - 加氢量 - -
- 时间单位:日 · 单位 Kg -
-
-
- {daily.trend.length === 0 ? ( - 当前日期范围暂无按日数据 - ) : ( - - - - String(value).slice(5)} - axisLine={false} - tickLine={false} - /> - number(Number(v))} - /> - [kg(Number(value)), "加氢量"]} - /> - - - - )} -
-
-
-
-
- 每日加氢数据明细{" "} - - (可多层下钻:按日 → 加氢站 → 客户 → 车辆及数据源) - -
-
- {daily.days.length === 0 ? ( - 当前日期范围暂无按日明细 - ) : ( - - - - - - - - - - - - {daily.days.map((row) => ( - onDrill("day")} - > - - - - - - - ))} - -
日期加氢量成本流水笔数站点
- {row.date}{" "} - 钻取 › - {kg(row.kg)}{yuan(row.cost)}{number(row.recordCount)}{number(row.stationCount)}
- )} -
-
- ); -} - -function DrillModal({ - query, - title, - onClose, -}: { - query: H2BiQuery & { metric: H2BiDrillMetric }; - title: string; - onClose: () => void; -}) { - const [search, setSearch] = useState(""); - const state = useRemoteData(`drill:${JSON.stringify(query)}`, () => - fetchH2BiDrill({ ...query, groupBy: "record", page: 1, pageSize: 100 }), - ); - const data: H2BiDrillResponse | null = state.data; - const records = useMemo( - () => - (data?.records || []).filter((record) => - JSON.stringify(record) - .toLocaleLowerCase() - .includes(search.trim().toLocaleLowerCase()), - ), - [data, search], - ); - const columns = useMemo( - () => - [ - "time", - "orderNo", - "stationName", - "customerName", - "plateNo", - "vehicleScope", - "source", - "verifyStatus", - "kg", - "unitPrice", - "cost", - "revenue", - ].filter((column) => - records.some((record) => record[column] !== undefined), - ), - [records], - ); - const fieldLabel: Record = { - time: "加氢时间", - orderNo: "订单号", - stationName: "加氢站", - customerName: "客户", - plateNo: "车牌号", - vehicleScope: "车辆归属", - source: "数据源", - verifyStatus: "核对状态", - kg: "加氢量 (Kg)", - unitPrice: "单价 (元/Kg)", - cost: "成本 (元)", - revenue: "收入 (元)", - }; - return ( -
-
-
-
- -
-
{title}明细
-
- 真实氢费数据下钻 · 跟随当前筛选条件 -
-
-
-
- -
-
-
- {state.loading ? ( - 正在加载真实明细数据… - ) : state.error ? ( - {state.error} - ) : ( - <> -
-
- 筛选范围 - - {query.startDate && query.endDate - ? `${query.startDate} 至 ${query.endDate}` - : `${query.year} 年`} - -
-
- 记录数 - - {number( - typeof data?.summary.recordCount === "number" - ? data.summary.recordCount - : 0, - )} - -
-
- 加氢量 - - {number( - typeof data?.summary.kg === "number" - ? data.summary.kg - : 0, - 2, - )}{" "} - Kg - -
-
-
-
- - 下钻结果使用当前真实数据源,不回填或补齐任何业务数值。 - -
- -
-
- {records.length === 0 ? ( - 暂无匹配的下钻记录 - ) : ( - - - - {columns.map((column) => ( - - ))} - - - - {records.map((record, index) => ( - - {columns.map((column) => ( - - ))} - - ))} - -
{fieldLabel[column] || column}
{formatRecord(record[column])}
- )} -
- - )} -
-
-
- ); -} - -function formatRecord(value: H2BiDrillRecord[string]) { - if (value === null || value === undefined || value === "") return "—"; - if (value === "lingniu") return "羚牛车辆"; - if (value === "external") return "外部车辆"; - if (value === "verified") return "已核对"; - if (value === "unverified") return "未核对"; - if (typeof value === "number") return number(value, 2); - return String(value); -} - -function boardQuery(query: H2BiQuery) { - return JSON.stringify(query); -} - -export default function HydrogenBiV2App() { - const [scope, setScope] = useState("global"); - const [view, setView] = useState("overview"); - const [year, setYear] = useState(now.getFullYear()); - const [stationId, setStationId] = useState(null); - const [vehicleScope, setVehicleScope] = useState("all"); - const [verifyScope, setVerifyScope] = useState("all"); - const [startDate, setStartDate] = useState(defaultStartDate); - const [endDate, setEndDate] = useState(defaultEndDate); - const [reload, setReload] = useState(0); - const [drill, setDrill] = useState<{ - metric: H2BiDrillMetric; - title: string; - stationId?: string | number | null; - } | null>(null); - const metaState = useRemoteData("meta", fetchH2BiMeta); - const meta: H2BiMetaResponse | null = metaState.data; - - useEffect(() => { - if (meta?.years.length && !meta.years.some((item) => item.value === year)) - setYear(meta.years[0].value); - }, [meta, year]); - - const query = useMemo( - () => ({ - year, - startDate: view === "daily" ? startDate : undefined, - endDate: view === "daily" ? endDate : undefined, - stationId: scope === "station" ? stationId : null, - vehicleScope, - verifyScope, - }), - [ - year, - startDate, - endDate, - stationId, - scope, - vehicleScope, - verifyScope, - view, - ], - ); - const overviewState = useRemoteData( - `overview:${boardQuery(query)}:${reload}`, - () => fetchH2BiOverview(query), - ); - const dailyState = useRemoteData(`daily:${boardQuery(query)}:${reload}`, () => - fetchH2BiDaily(query), - ); - const activeState = view === "overview" ? overviewState : dailyState; - const activeRange = - view === "overview" ? overviewState.data?.range : dailyState.data?.range; - const stationName = meta?.stations.find( - (station) => station.id === stationId, - )?.name; - - const openDrill = ( - metric: H2BiDrillMetric, - targetStationId?: string | number | null, - ) => { - const titles: Record = { - totalKg: "加氢量", - totalCost: "成本", - totalRevenue: "氢费收入", - customerGrossProfit: "客户毛利", - monthKg: "本月加氢", - todayKg: "本日加氢", - station: "加氢站", - customer: "客户账单", - day: "按日加氢", - }; - setDrill({ metric, title: titles[metric], stationId: targetStationId }); - }; - - return ( -
-
-
-
-
羚牛氢能 BI / 氢能
-
-

氢能经营看板

- {scope === "global" ? ( - - 📅 统计时间范围:{rangeText(activeRange)} - - ) : null} -
-
-
-
- - -
- {scope === "global" ? ( -
- - -
- ) : null} -
-
-
- - -
-
-
-
- item.value)} - onChange={setYear} - /> - {scope === "station" ? ( - - ) : null} -
- - -
-
-
- {view === "daily" ? ( - <> - setStartDate(event.target.value)} - aria-label="开始日期" - /> - setEndDate(event.target.value)} - aria-label="结束日期" - /> - - ) : null} -
- - - -
- - {activeState.data?.watermark.ledgerAt || "加载中…"} - - - {stationName ? ( - - 当前单站:{stationName} - - ) : null} -
-
-
- {metaState.error ? ( -
-
- 筛选元数据加载失败:{metaState.error} -
-
- ) : null} - {activeState.loading && !activeState.data ? ( -
- -
正在加载真实氢能数据…
-
- ) : null} - {activeState.error ? ( -
-
{activeState.error}
- -
- ) : null} - {view === "overview" && overviewState.data ? ( - - ) : null} - {view === "daily" && dailyState.data ? ( - - ) : null} -
- {drill ? ( - setDrill(null)} - /> - ) : null} -
- ); -} diff --git a/src/modules/energy/hydrogen-bi-v2/PrototypeBoard.tsx b/src/modules/energy/hydrogen-bi-v2/PrototypeBoard.tsx deleted file mode 100644 index 51226f3..0000000 --- a/src/modules/energy/hydrogen-bi-v2/PrototypeBoard.tsx +++ /dev/null @@ -1,8084 +0,0 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; -import { - Activity, - Calendar, - ChevronDown, - ChevronLeft, - ChevronRight, - Download, - Fuel, - MoreHorizontal, - RefreshCw, - Search, - Shield, - TrendingUp, - Truck, - Wallet, - X, - Zap, -} from "lucide-react"; -import { downloadExcelAoa } from "./prototype-download"; -import { - SOURCE_LABEL, - companyRowsForStats, - computeHostKpi, - costDimCards, - costDimLabel, - customerAttrAgg, - filterOrders, - formatKg, - formatYuan, - pendingAmount, - stationMonthAgg, - type DimFilter, - unverified, -} from "./prototype-source/data/aggregates"; -import { - DEFAULT_YEAR, - HOST_KPI, - MOCK_ORDERS, -} from "./prototype-source/data/mockBoard"; -import { - DAILY_VERIFY_LABEL, - MOCK_DAILY_15DAYS, - SOURCE_TYPE_LABEL, - STATION_TYPE_LABEL, - calculateDailyKpis, - filterDailyDataByFleet, - getDailyDataForRange, - type FleetCategory, - type FleetCategoryFilter, -} from "./prototype-source/data/mockDaily"; -import type { - FleetScope, - HostView, - H2OrderRow, -} from "./prototype-source/types"; -import HydrogenStationBoard from "../HydrogenStationBoard"; -import { loadPrototypeOverview } from "./prototype-adapter"; -import { - PrototypeDrillModal, - prototypeFleetScope, -} from "./prototype-real-drills"; -import { PrototypeRealDailyView } from "./prototype-real-daily"; -import type { H2BiOverviewResponse, H2BiQuery } from "./types"; -import "./prototype-source/styles/energy-bi-board.css"; -import "./drill-prototype-parity.css"; - -const StationDailyApp = (props: { embedded?: boolean }) => ( - -); -const toLocalYmd = (date: Date) => - `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; -const toLocalDateTime = (date: Date) => - `${toLocalYmd(date)} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}:${String(date.getSeconds()).padStart(2, "0")}`; -const defaultDailyRange = () => { - const end = new Date(); - const start = new Date(end); - start.setDate(start.getDate() - 14); - return { start: toLocalYmd(start), end: toLocalYmd(end) }; -}; - -type BoardScope = "global" | "station"; -type StatsTab = "siteMonth" | "customer"; - -interface BiYearSelectProps { - value: number; - onChange: (year: number) => void; -} - -function BiYearSelect({ value, onChange }: BiYearSelectProps) { - const [isOpen, setIsOpen] = useState(false); - const ref = useRef(null); - - // 提供从 2026 到 2020 年份列表,满足历史多年数据查阅诉求 - const years = [2026, 2025, 2024, 2023, 2022, 2021, 2020]; - - useEffect(() => { - function handleClickOutside(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) { - setIsOpen(false); - } - } - if (isOpen) { - document.addEventListener("mousedown", handleClickOutside); - } - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, [isOpen]); - - return ( -
- - - {isOpen && ( -
-
切换数据年份
-
- {years.map((y) => ( - - ))} -
-
- )} -
- ); -} - -/** BI 皮 · 可搜索选择器(禁 V2;穿透筛专用) */ -interface BiSearchSelectOption { - value: string; - label: string; -} - -function BiSearchSelect({ - value, - onChange, - options, - allLabel, - placeholder = "搜索…", - width = 180, - disabled = false, -}: { - value: string; - onChange: (next: string) => void; - options: BiSearchSelectOption[]; - allLabel: string; - placeholder?: string; - width?: number; - disabled?: boolean; -}) { - const [open, setOpen] = useState(false); - const [query, setQuery] = useState(""); - const ref = useRef(null); - const inputRef = useRef(null); - - useEffect(() => { - function onDoc(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) - setOpen(false); - } - if (open) document.addEventListener("mousedown", onDoc); - return () => document.removeEventListener("mousedown", onDoc); - }, [open]); - - useEffect(() => { - if (open) { - setQuery(""); - requestAnimationFrame(() => inputRef.current?.focus()); - } - }, [open]); - - const selectedLabel = - value === "all" - ? allLabel - : options.find((o) => o.value === value)?.label || allLabel; - - const filtered = useMemo(() => { - const q = query.trim().toLowerCase(); - if (!q) return options; - return options.filter( - (o) => - o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q), - ); - }, [options, query]); - - return ( -
- - {open && !disabled && ( -
-
- - setQuery(e.target.value)} - placeholder={placeholder} - onClick={(e) => e.stopPropagation()} - /> -
-
- - {filtered.map((o) => ( - - ))} - {filtered.length === 0 && ( -
无匹配项
- )} -
-
- )} -
- ); -} - -// 站加氢汇总假数据 (带省份归属,与用户截图高保真100%对齐) -const MOCK_STATION_SUMMARY_LIST = [ - { - rank: 1, - name: "嘉兴中石化滨海加氢站", - province: "浙江省", - kgT: "243.66", - kgPct: 35.0, - incomeWan: "52.66", - incomePct: 7.1, - }, - { - rank: 2, - name: "嘉兴嘉锦加氢站", - province: "浙江省", - kgT: "182.89", - kgPct: 26.2, - incomeWan: "119.53", - incomePct: 16.1, - }, - { - rank: 3, - name: "嘉兴嘉燃加氢站", - province: "浙江省", - kgT: "28.23", - kgPct: 4.0, - incomeWan: "61.47", - incomePct: 8.3, - }, - { - rank: 4, - name: "桐乡中石化绿能加氢站", - province: "浙江省", - kgT: "26.08", - kgPct: 3.7, - incomeWan: "35.76", - incomePct: 4.8, - }, - { - rank: 5, - name: "成都中石化天府机场高速北站加氢站", - province: "四川省", - kgT: "22.93", - kgPct: 3.3, - incomeWan: "68.62", - incomePct: 9.2, - }, - { - rank: 6, - name: "花桥中石油加氢站", - province: "江苏省", - kgT: "19.71", - kgPct: 2.8, - incomeWan: "35.15", - incomePct: 4.7, - }, - { - rank: 7, - name: "常熟嘉化加氢站", - province: "江苏省", - kgT: "15.56", - kgPct: 2.2, - incomeWan: "25.81", - incomePct: 3.5, - }, - { - rank: 8, - name: "嘉善中石化站前路加氢站", - province: "浙江省", - kgT: "14.61", - kgPct: 2.1, - incomeWan: "25.34", - incomePct: 3.4, - }, - { - rank: 9, - name: "嘉兴东方港湾加氢站", - province: "浙江省", - kgT: "13.45", - kgPct: 1.9, - incomeWan: "7.01", - incomePct: 0.9, - }, - { - rank: 10, - name: "成都中石化天府机场高速南站加氢站", - province: "四川省", - kgT: "12.86", - kgPct: 1.8, - incomeWan: "38.46", - incomePct: 5.2, - }, - { - rank: 11, - name: "成都国氢华通加氢站", - province: "四川省", - kgT: "12.09", - kgPct: 1.7, - incomeWan: "36.26", - incomePct: 4.9, - }, - { - rank: 12, - name: "广州新锋交通联新加氢站", - province: "广东省", - kgT: "9.33", - kgPct: 1.3, - incomeWan: "13.49", - incomePct: 1.8, - }, - { - rank: 13, - name: "乌鲁木齐隆盛达沙坪加氢站", - province: "新疆维吾尔自治区", - kgT: "9.33", - kgPct: 1.3, - incomeWan: "21.12", - incomePct: 2.8, - }, - { - rank: 14, - name: "佛山豪石油加氢站", - province: "广东省", - kgT: "8.96", - kgPct: 1.3, - incomeWan: "26.6", - incomePct: 3.6, - }, - { - rank: 15, - name: "佛南海羚牛加氢站", - province: "广东省", - kgT: "7.10", - kgPct: 1.0, - incomeWan: "3.29", - incomePct: 0.4, - }, - { - rank: 16, - name: "佛山中石化佛西加氢站", - province: "广东省", - kgT: "5.83", - kgPct: 0.8, - incomeWan: "20.32", - incomePct: 2.7, - }, - { - rank: 17, - name: "广州中石化东明三路加氢站", - province: "广东省", - kgT: "5.62", - kgPct: 0.8, - incomeWan: "5.73", - incomePct: 0.8, - }, - { - rank: 18, - name: "常熟AP银河路加氢站", - province: "江苏省", - kgT: "4.98", - kgPct: 0.7, - incomeWan: "20.06", - incomePct: 2.7, - }, - { - rank: 19, - name: "武汉中石化革新加氢站", - province: "湖北省", - kgT: "3.95", - kgPct: 0.6, - incomeWan: "9.45", - incomePct: 1.3, - }, - { - rank: 20, - name: "韶关韶钢加氢站", - province: "广东省", - kgT: "3.72", - kgPct: 0.5, - incomeWan: "10.81", - incomePct: 1.5, - }, - { - rank: 21, - name: "成都博能加氢站", - province: "四川省", - kgT: "3.49", - kgPct: 0.5, - incomeWan: "10.21", - incomePct: 1.4, - }, - { - rank: 22, - name: "无锡润硕氢能加氢站", - province: "江苏省", - kgT: "3.22", - kgPct: 0.5, - incomeWan: "11.98", - incomePct: 1.6, - }, - { - rank: 23, - name: "上海安亭加氢站", - province: "上海市", - kgT: "3.10", - kgPct: 0.4, - incomeWan: "9.82", - incomePct: 1.3, - }, - { - rank: 24, - name: "昆山千灯加氢站", - province: "江苏省", - kgT: "2.88", - kgPct: 0.4, - incomeWan: "8.90", - incomePct: 1.2, - }, - { - rank: 25, - name: "宁波港区示范加氢站", - province: "浙江省", - kgT: "2.45", - kgPct: 0.3, - incomeWan: "7.65", - incomePct: 1.0, - }, -]; - -// 客户账单汇总假数据 (Top 30 与用户截图高保真100%对齐) -const MOCK_CUSTOMER_SUMMARY_LIST = [ - { - rank: 1, - name: "嘉兴市乍浦港口经营有限公司", - bearer: "cust" as const, - kgT: "288.37", - costWan: "807.94", - receivable: "¥1,987 元", - }, - { - rank: 2, - name: "嘉兴益顺冷链物流有限公司", - bearer: "cust" as const, - kgT: "38.93", - costWan: "136.83", - receivable: "¥66.25 万元", - }, - { - rank: 3, - name: "嘉兴智奇供应链管理有限公司", - bearer: "cust" as const, - kgT: "35.45", - costWan: "101.73", - receivable: "¥14.44 万元", - }, - { - rank: 4, - name: "车辆异动", - bearer: "cust" as const, - kgT: "28.10", - costWan: "92.96", - receivable: "¥2,503 元", - }, - { - rank: 5, - name: "四川群彬物流有限公司", - bearer: "cust" as const, - kgT: "23.35", - costWan: "69.98", - receivable: "¥69.98 万元", - }, - { - rank: 6, - name: "浙江洋井供应链管理有限公司", - bearer: "cust" as const, - kgT: "18.62", - costWan: "61.08", - receivable: "¥321 元", - }, - { - rank: 7, - name: "无锡铭康物流有限公司-1", - bearer: "lingniu" as const, - kgT: "16.89", - costWan: "58.38", - receivable: "¥0 元", - }, - { - rank: 8, - name: "上海明纳物流有限公司", - bearer: "lingniu" as const, - kgT: "12.44", - costWan: "41.4", - receivable: "¥0 元", - }, - { - rank: 9, - name: "嘉兴中外运物流有限公司", - bearer: "lingniu" as const, - kgT: "11.39", - costWan: "31.94", - receivable: "¥0 元", - }, - { - rank: 10, - name: "重庆金时源供应链有限公司", - bearer: "cust" as const, - kgT: "11.18", - costWan: "27.96", - receivable: "¥27.96 万元", - }, - { - rank: 11, - name: "四川拱照物流有限公司", - bearer: "cust" as const, - kgT: "10.34", - costWan: "31", - receivable: "¥31 万元", - }, - { - rank: 12, - name: "无锡铭康物流有限公司", - bearer: "lingniu" as const, - kgT: "9.21", - costWan: "31.72", - receivable: "¥0 元", - }, - { - rank: 13, - name: "嘉兴羚利供应链科技有限公司", - bearer: "cust" as const, - kgT: "8.62", - costWan: "24.13", - receivable: "¥25.85 万元", - }, - { - rank: 14, - name: "宁波港集装箱运输有限公司嘉兴分公司", - bearer: "cust" as const, - kgT: "8.09", - costWan: "22.66", - receivable: "¥23.01 万元", - }, - { - rank: 15, - name: "成都诺和物流有限公司", - bearer: "cust" as const, - kgT: "6.67", - costWan: "19.99", - receivable: "¥19.99 万元", - }, - { - rank: 16, - name: "嘉兴市飞宇物流有限公司", - bearer: "cust" as const, - kgT: "6.49", - costWan: "18.22", - receivable: "¥6,483 元", - }, - { - rank: 17, - name: "嘉兴港区众通快递有限公司", - bearer: "cust" as const, - kgT: "6.27", - costWan: "17.55", - receivable: "¥18.81 万元", - }, - { - rank: 18, - name: "浙江集佑供应链有限公司", - bearer: "cust" as const, - kgT: "6.20", - costWan: "17.35", - receivable: "¥18.59 万元", - }, - { - rank: 19, - name: "日邮物流(中国)有限公司", - bearer: "cust" as const, - kgT: "5.90", - costWan: "22.11", - receivable: "¥22.2 万元", - }, - { - rank: 20, - name: "四川邦达蜀运供应链管理有限公司", - bearer: "cust" as const, - kgT: "5.21", - costWan: "15.63", - receivable: "¥15.65 万元", - }, - { - rank: 21, - name: "嘉兴古道物流有限公司", - bearer: "cust" as const, - kgT: "5.18", - costWan: "14.49", - receivable: "¥15.53 万元", - }, - { - rank: 22, - name: "宁波乐驰物流有限公司", - bearer: "cust" as const, - kgT: "5.01", - costWan: "14.01", - receivable: "¥14.1 万元", - }, - { - rank: 23, - name: "广东清运物流专线", - bearer: "cust" as const, - kgT: "4.82", - costWan: "13.50", - receivable: "¥13.50 万元", - }, - { - rank: 24, - name: "顺丰冷运嘉兴分线", - bearer: "cust" as const, - kgT: "4.21", - costWan: "11.78", - receivable: "¥11.80 万元", - }, - { - rank: 25, - name: "极兔速递冷链事业部", - bearer: "cust" as const, - kgT: "3.95", - costWan: "11.06", - receivable: "¥11.06 万元", - }, - { - rank: 26, - name: "武汉捷运货运有限公司", - bearer: "cust" as const, - kgT: "3.62", - costWan: "10.13", - receivable: "¥10.15 万元", - }, - { - rank: 27, - name: "成都天府物流二部", - bearer: "cust" as const, - kgT: "3.11", - costWan: "8.70", - receivable: "¥8.70 万元", - }, - { - rank: 28, - name: "广州黄埔冷链车队", - bearer: "cust" as const, - kgT: "2.85", - costWan: "7.98", - receivable: "¥8.00 万元", - }, - { - rank: 29, - name: "常熟物流储运中心", - bearer: "cust" as const, - kgT: "2.40", - costWan: "6.72", - receivable: "¥6.72 万元", - }, - { - rank: 30, - name: "无锡灵通运输公司", - bearer: "cust" as const, - kgT: "2.10", - costWan: "5.88", - receivable: "¥5.90 万元", - }, -]; - -interface OverviewTrendsProps { - year: number; - fleetScope: FleetScope; - verifyScope: "all" | "verified"; - liveOverview: Awaited>; - onOpenDrill: (label: string) => void; - onOpenCustomerBill: (custName: string) => void; - onOpenStationBill: ( - stName: string, - province: string, - stationId: string | number | null, - ) => void; -} - -function OverviewTrendsDashboard({ - year, - fleetScope, - verifyScope, - liveOverview, - onOpenDrill, - onOpenCustomerBill, - onOpenStationBill, -}: OverviewTrendsProps) { - const monthlyData = liveOverview.monthlyQuantity; - const rangeText = `${liveOverview.range.startDate} 至 ${liveOverview.range.endDate}`; - const regionTotalT = (liveOverview.kpi.totalKg / 1000).toLocaleString("zh-CN", { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }); - // The live API uses YYYY-MM keys. Keep the UI label human-readable while - // preserving a parseable month context for the drill-down state machine. - const displayMonth = (month: string) => { - const [monthYear, monthNumber] = month.split("-"); - return /^\d{4}$/.test(monthYear) && /^\d{1,2}$/.test(monthNumber) - ? `${monthYear}年${Number(monthNumber)}月` - : month; - }; - // 横轴遵循原型:年份已由图表标题交代,柱下仅保留月份。 - const displayMonthShort = (month: string) => { - const [, monthNumber] = month.split("-"); - return /^\d{1,2}$/.test(monthNumber) ? `${Number(monthNumber)}月` : month; - }; - const displayTons = (kg: number) => - `${(kg / 1000).toLocaleString("zh-CN", { - minimumFractionDigits: 1, - maximumFractionDigits: 1, - })} 吨`; - const maxMonthlyKg = useMemo(() => { - return Math.max(...monthlyData.map((d) => d.totalKg), 1); - }, [monthlyData]); - - const monthlyRevenueData = liveOverview.monthlyRevenue.map((item) => ({ - m: item.month, - customerAmount: item.customerAmount, - customerCost: item.customerCost, - companyCost: item.companyCost, - otherCost: item.otherCost, - top9: [] as Array<{ name: string; amount: number }>, - restAmount: 0, - restCount: 0, - costDetails: [ - { label: "对客成本", amount: item.customerCost, scope: "customer" as const }, - { label: "我司承担成本", amount: item.companyCost, scope: "company" as const }, - { label: "其他成本", amount: item.otherCost, scope: "other" as const }, - ], - })); - - const maxRevenueVal = useMemo(() => { - return Math.max( - ...monthlyRevenueData.flatMap((d) => [ - d.customerAmount, - d.customerCost + d.companyCost + d.otherCost, - ]), - 1, - ); - }, [monthlyRevenueData]); - - const topStations = liveOverview.topStations.map((station) => ({ - ...station, - val: station.kg, - pct: Math.round( - (station.kg / Math.max(liveOverview.topStations[0]?.kg || 1, 1)) * 100, - ), - })); - - // 区域维度控制: 按市 ('city') | 按省 ('province') - const [regionGranularity, setRegionGranularity] = useState< - "province" | "city" - >("city"); - const [regionLegendExpanded, setRegionLegendExpanded] = useState(false); - - // 省份筛选控制: 'all' | '浙江省' | '四川省' | '广东省' | '江苏省' | '湖北省' 等 - const [selectedProvince, setSelectedProvince] = useState("all"); - const [provinceMenuOpen, setProvinceMenuOpen] = useState(false); - const provinceTabsRef = useRef(null); - const [stationListExpanded, setStationListExpanded] = useState(false); - const [customerListExpanded, setCustomerListExpanded] = useState(false); - - const stationSummaryList = liveOverview.stations - .filter((station) => Number(station.kg) > 0) - .map((station, index) => ({ - rank: index + 1, - id: station.id, - name: station.name, - province: station.province || "未归属", - city: station.city || "未归属", - kgT: (station.kg / 1000).toFixed(2), - kgPct: station.share, - incomeWan: (station.customerRevenue / 10000).toFixed(2), - incomePct: - liveOverview.kpi.customerRevenue > 0 - ? (station.customerRevenue / liveOverview.kpi.customerRevenue) * 100 - : 0, - })); - const customerSummaryList = liveOverview.customers.map((customer, index) => ({ - rank: index + 1, - id: customer.id, - name: customer.name, - // 承担方和车辆归属是两个维度:这里按真实订单的成本承担关系展示, - // 不以 vehicle_id(羚牛/外部车辆)替代承担方。 - bearer: - customer.bearer === "both" - ? ("both" as const) - : customer.bearer === "customer" - ? ("cust" as const) - : customer.bearer === "company" - ? ("company" as const) - : ("other" as const), - kgT: (customer.kg / 1000).toFixed(2), - costWan: (customer.customerCost / 10000).toFixed(2), - receivable: `¥${(customer.customerRevenue / 10000).toFixed(2)} 万元`, - })); - - const availableProvinces = useMemo(() => { - const list: string[] = ["all"]; - stationSummaryList.forEach((st) => { - if (st.province && !list.includes(st.province)) { - list.push(st.province); - } - }); - return list; - }, [stationSummaryList]); - - // 根据选定省份精准过滤加氢站列表 - const filteredStationList = useMemo(() => { - if (selectedProvince === "all") return stationSummaryList; - return stationSummaryList.filter((st) => st.province === selectedProvince); - }, [selectedProvince, stationSummaryList]); - const visibleStationList = stationListExpanded - ? filteredStationList - : filteredStationList.slice(0, 10); - const visibleCustomerList = customerListExpanded - ? customerSummaryList - : customerSummaryList.slice(0, 10); - - useEffect(() => { - if (!provinceMenuOpen) return; - const closeMenu = (event: MouseEvent) => { - if (!provinceTabsRef.current?.contains(event.target as Node)) { - setProvinceMenuOpen(false); - } - }; - document.addEventListener("mousedown", closeMenu); - return () => document.removeEventListener("mousedown", closeMenu); - }, [provinceMenuOpen]); - - const selectProvince = (province: string) => { - setSelectedProvince(province); - setStationListExpanded(false); - setProvinceMenuOpen(false); - }; - - // 根据过滤结果计算总站数 (全国 65 站基准,按比例联动) - const stationCountDisplay = useMemo(() => { - return `共 ${filteredStationList.length} 站`; - }, [selectedProvince, filteredStationList]); - - // 按市区域占比数据 (规范地级市名称) - const regionColors = [ - "#0284c7", - "#38bdf8", - "#10b981", - "#f59e0b", - "#8b5cf6", - "#ec4899", - "#06b6d4", - "#84cc16", - "#94a3b8", - ]; - const makeRegions = (key: "province" | "city") => { - const totals = stationSummaryList.reduce>( - (result, station) => { - const label = station[key] || "未归属"; - result[label] = (result[label] || 0) + Number(station.kgT) * 1000; - return result; - }, - {}, - ); - const total = - Object.values(totals).reduce((sum, value) => sum + value, 0) || 1; - let offset = 0; - return Object.entries(totals) - .sort((a, b) => b[1] - a[1]) - .map(([label, value], index) => { - const length = (value / total) * 238; - const row = { - label, - pct: `${((value / total) * 100).toFixed(1)}%`, - color: regionColors[index % regionColors.length], - dashArray: `${length} 238`, - dashOffset: `${-offset}`, - }; - offset += length; - return row; - }); - }; - const cityRegions = makeRegions("city"); - const provinceRegions = makeRegions("province"); - - const activeRegions = - regionGranularity === "province" ? provinceRegions : cityRegions; - const visibleRegions = regionLegendExpanded - ? activeRegions - : activeRegions.slice(0, 10); - - return ( -
- {/* 1. 月度加氢量趋势柱图 */} -
-
-
{year} 年月度加氢量
-
- - - 内部客户 - - - - 外部客户 - - - 统计范围:{rangeText} · 单位 吨 - -
-
- - -
- {monthlyData.map((d) => { - const heightPct = Math.min( - 100, - Math.round((d.totalKg / maxMonthlyKg) * 100), - ); - const ownRatio = - d.totalKg > 0 ? Math.round((d.ownKg / d.totalKg) * 100) : 60; - const extRatio = Math.max(0, 100 - ownRatio); - - return ( - - ); - })} -
-
- - {/* 2. 月度收支对比柱图 */} -
-
-
{year} 年月度收支对比
-
- - 收入 - - - 对客 - - - - 成本 - - - 对客 - - - - 我司 - - - - 其他 - - - - 统计范围:{rangeText} · 单位 元 - -
-
- - -
- {monthlyRevenueData.map((d) => { - const incPct = Math.min( - 100, - Math.round((d.customerAmount / maxRevenueVal) * 100), - ); - const totalCost = d.customerCost + d.companyCost + d.otherCost; - const costPct = Math.min( - 100, - Math.round((totalCost / maxRevenueVal) * 100), - ); - - return ( -
-
-
-
- {d.costDetails.map((item) => - item.amount > 0 ? ( -
-
-
- -
-
-
{displayMonthShort(d.m)}
-
- ); - })} -
-
- - {/* 3 & 4. 下方并排:Top5 站加氢量 + 各区域加氢占比 */} -
- {/* Top5 站 */} -
-
-
加氢站加氢量 Top5
-
- - - 内部客户 - - - - 外部客户 - - - 统计范围:{rangeText} · 单位 Kg - -
-
- -
- {topStations.map((st) => { - const ownRatio = Math.round((st.ownKg / st.val) * 100); - const extRatio = 100 - ownRatio; - - return ( - - ); - })} -
-
- - {/* 各区域加氢占比 (支持按省 / 按市快速切换) */} -
-
-
各区域加氢占比
-
- - -
-
- -
-
- - - {activeRegions.map((reg) => ( - - onOpenDrill( - `区域${regionGranularity === "city" ? "市" : "省"}:${reg.label}`, - ) - } - > - {`点击钻取${reg.label}各加氢站加氢总量与占比`} - - ))} - -
-
年合计
-
{regionTotalT}T
-
-
- -
-
- {visibleRegions.map((reg) => ( - - ))} -
- {activeRegions.length > 10 ? ( - - ) : null} -
-
-
-
- - {/* 5. 趋势图下方:加氢站加氢汇总表 (支持区域按省筛选切换) */} -
-
-
-
加氢站加氢汇总
- {/* 区域省份切换控制 (仅展示已有加氢站的省份) */} -
-
- {availableProvinces.map((prov) => ( - - ))} - {availableProvinces.length > 5 ? ( - - ) : null} -
- {provinceMenuOpen ? ( -
- {availableProvinces.map((prov) => ( - - ))} -
- ) : null} -
-
-
- 统计范围:{rangeText} · {stationCountDisplay} -
-
- - -
- - - - - - - - - - - - - - {visibleStationList.map((st, idx) => ( - onOpenStationBill(st.name, st.province, st.id)} - style={{ cursor: "pointer" }} - title="点击钻取:加氢量 / 占比 / 对客金额 / 对客金额占比" - > - - - - - - - - - ))} - -
#加氢站(点击钻取)所属省份加氢量占比对客金额对客金额占比
{idx + 1} - {st.name}{" "} - - 钻取 › - - - - {st.province} - - - {st.kgT}{" "} - - T - - -
-
-
-
- - {st.kgPct.toFixed(1)}% - -
-
- ¥{st.incomeWan}{" "} - 万元 - -
-
-
-
- - {st.incomePct.toFixed(1)}% - -
-
-
- {filteredStationList.length > 10 ? ( - - ) : null} -
- - {/* 6. 趋势图下方:客户账单汇总表 (Top 30) */} -
-
-
- 客户账单汇总 - - (已收 / 未收:等待客户能源账户和对账单打通后获取) - - - (已收未收打通中) - -
-
- 统计范围:{rangeText} · Top 30 -
-
- - -
- - - - - - - - - - - - - - - {visibleCustomerList.map((cust) => ( - onOpenCustomerBill(cust.name)} - style={{ cursor: "pointer" }} - title="点击钻取:承担方 / 加氢量 / 客户承担成本金额 / 对客金额 / 已收 / 未收" - > - - - - - - - - - - ))} - -
#客户(点击钻取)承担方加氢量客户承担成本金额对客金额 - 已收 - - 未收 -
{cust.rank} - {cust.name}{" "} - - 钻取 › - - - - {cust.bearer === "both" - ? "客户 + 我司" - : cust.bearer === "cust" - ? "客户承担" - : cust.bearer === "company" - ? "我司承担" - : "其他"} - - - {cust.kgT}{" "} - - T - - - ¥{cust.costWan}{" "} - 万元 - - {cust.receivable} - - - 敬请期待 - - - - 敬请期待 - -
-
- {customerSummaryList.length > 10 ? ( - - ) : null} -
-
- ); -} - -/** - * 嵌入 bi-next `#hydrogen/overview`: - * - 壳 / KPI / 洞察对齐宿主 zip 视觉 - * - 独立功能块:三维度 + 站月/客户汇总 + 订单明细 · 禁用 OneOS V2 - */ -export const EnergyBiBoardApp: React.FC = () => { - const [boardScope, setBoardScope] = useState("global"); - const [hostView, setHostView] = useState("overview"); - const [year, setYear] = useState(DEFAULT_YEAR); - const [fleetScope, setFleetScope] = useState("all"); - const [verifyScope, setVerifyScope] = useState<"all" | "verified">("all"); - const [dimFilter, setDimFilter] = useState(null); - const [statsTab, setStatsTab] = useState("siteMonth"); - const [stationId, setStationId] = useState(null); - const [stationLabel, setStationLabel] = useState(null); - const [customerId, setCustomerId] = useState(null); - const [customerLabel, setCustomerLabel] = useState(null); - - // KPI 点击下钻 Modal 状态 - const [kpiDrillType, setKpiDrillType] = useState(null); - // 头部加氢站占比 → 加氢量排名下拉 - const [stationRankOpen, setStationRankOpen] = useState(false); - const stationRankRef = useRef(null); - - // 客户账单专属下钻 Modal 状态 (客户 → 日期 → 车牌加氢记录) - const [selectedBillCustomer, setSelectedBillCustomer] = useState< - string | null - >(null); - - // 加氢站账单专属下钻 Modal 状态 (加氢站 → 所有日期的加氢量、占比、氢费收入、收入占比) - const [selectedStationForDrill, setSelectedStationForDrill] = useState<{ - id: string | number | null; - name: string; - province: string; - } | null>(null); - - // 按日视角日期区间状态 (提升至顶层供标题旁时间范围联动) - const [dailyStartDate, setDailyStartDate] = useState( - () => defaultDailyRange().start, - ); - const [dailyEndDate, setDailyEndDate] = useState( - () => defaultDailyRange().end, - ); - const [liveOverview, setLiveOverview] = useState - > | null>(null); - const [liveOverviewError, setLiveOverviewError] = useState( - null, - ); - const [liveReload, setLiveReload] = useState(0); - const [lastRefreshedAt, setLastRefreshedAt] = useState(() => - toLocalDateTime(new Date()), - ); - - useEffect(() => { - let active = true; - // 年份/筛选一变化,先清除旧年数据;请求完成前不能短暂显示 2026 年总览。 - setLiveOverview(null); - setLiveOverviewError(null); - const query: H2BiQuery = { - year, - stationId: null, - vehicleScope: fleetScope === "own" ? "lingniu" : fleetScope, - verifyScope, - }; - void loadPrototypeOverview(query) - .then((result) => { - if (!active) return; - setLiveOverview(result); - setLiveOverviewError(null); - setLastRefreshedAt(toLocalDateTime(new Date())); - }) - .catch((reason: unknown) => { - if (!active) return; - setLiveOverviewError( - reason instanceof Error ? reason.message : "真实氢能数据加载失败", - ); - }); - return () => { - active = false; - }; - }, [fleetScope, liveReload, verifyScope, year]); - - // 全局看板时间范围(单站模式不展示:维度不同,由站内查询日期自管) - const timeRangeLabel = "统计时间范围"; - const timeRangeText = useMemo(() => { - if (hostView === "daily") { - return `${dailyStartDate} 至 ${dailyEndDate}`; - } - return liveOverview?.range.startDate && liveOverview.range.endDate - ? `${liveOverview.range.startDate} 至 ${liveOverview.range.endDate}` - : `${year} 年`; - }, [hostView, dailyStartDate, dailyEndDate, liveOverview, year]); - - const clearEntity = () => { - setStationId(null); - setStationLabel(null); - setCustomerId(null); - setCustomerLabel(null); - }; - - const rows = useMemo( - () => filterOrders(MOCK_ORDERS, year, verifyScope, fleetScope), - [year, verifyScope, fleetScope], - ); - const hostKpi = useMemo(() => { - const rounded = (value: number, digits = 2) => - Number(value.toFixed(digits)); - const kpi = liveOverview?.kpi; - const customerAmount = kpi?.customerRevenue ?? 0; - const customerCost = kpi?.customerCost ?? 0; - const profit = kpi?.customerGrossProfit ?? 0; - const totalKgT = rounded((kpi?.totalKg ?? 0) / 1000); - const companyKgT = rounded((kpi?.companyBearingKg ?? 0) / 1000); - const otherKgT = rounded((kpi?.otherBearingKg ?? 0) / 1000); - return { - totalKgT, - companyKgT, - // Keep the three displayed bearers equal to the displayed total after - // rounding; the API retains the exact kilogram-level values. - customerKgT: rounded(totalKgT - companyKgT - otherKgT), - otherKgT, - totalCostWan: rounded((kpi?.totalCost ?? 0) / 10000), - companyCostWan: rounded((kpi?.companyCost ?? 0) / 10000), - otherCostWan: rounded((kpi?.otherCost ?? 0) / 10000), - customerAmountWan: rounded(customerAmount / 10000), - customerCostWan: rounded(customerCost / 10000), - profitWan: rounded(profit / 10000), - monthKgT: rounded((kpi?.monthKg ?? 0) / 1000), - monthFeeWan: rounded((kpi?.monthCost ?? 0) / 10000), - monthYearPct: rounded(kpi?.monthShareOfRange ?? 0), - dayKg: rounded(kpi?.todayKg ?? 0), - dayFee: rounded(kpi?.todayCost ?? 0), - dayMonthPct: rounded(kpi?.todayShareOfMonth ?? 0), - profitRatePct: - customerAmount > 0 - ? Math.round((profit / customerAmount) * 10000) / 100 - : 0, - }; - }, [liveOverview]); - - // 加氢站加氢量排名(高→低),跟随年份/车辆/核对筛选 - const stationRankList = useMemo(() => { - const list = (liveOverview?.stations ?? []) - .map((st) => ({ - id: st.id, - name: st.name, - province: st.province || "未归属", - kg: st.kg, - })) - .filter((st) => st.kg > 0) - .sort((a, b) => b.kg - a.kg); - const maxKg = list[0]?.kg || 1; - const totalKg = list.reduce((s, x) => s + x.kg, 0) || 1; - return list.map((st, i) => ({ - ...st, - rank: i + 1, - barPct: Math.round((st.kg / maxKg) * 100), - sharePct: Math.round((st.kg / totalKg) * 1000) / 10, - })); - }, [liveOverview]); - - const top5SharePct = useMemo(() => { - const top5 = stationRankList.slice(0, 5).reduce((s, x) => s + x.kg, 0); - const total = stationRankList.reduce((s, x) => s + x.kg, 0) || 1; - return Math.round((top5 / total) * 1000) / 10; - }, [stationRankList]); - - // 本月占比洞察副文案:当月 / 峰值月 / 月均(与图表月度口径一致,跟随筛选) - const monthFluctuationDesc = useMemo(() => { - const months = liveOverview?.monthlyQuantity ?? []; - if (months.length === 0) return "当前筛选范围暂无月度数据"; - const toT = (kg: number) => Math.round((kg / 1000) * 100) / 100; - const current = months[months.length - 1]; - const peak = months.reduce( - (best, cur) => (cur.totalKg > best.totalKg ? cur : best), - months[0], - ); - const avgKg = - months.reduce((s, m) => s + m.totalKg, 0) / (months.length || 1); - return `当月 ${toT(current.totalKg)} T · 峰值${peak.month} ${toT(peak.totalKg)} T · 月均 ${toT(avgKg)} T`; - }, [liveOverview]); - - useEffect(() => { - if (!stationRankOpen) return; - function onDoc(e: MouseEvent) { - if ( - stationRankRef.current && - !stationRankRef.current.contains(e.target as Node) - ) { - setStationRankOpen(false); - } - } - document.addEventListener("mousedown", onDoc); - return () => document.removeEventListener("mousedown", onDoc); - }, [stationRankOpen]); - - const dims = useMemo(() => costDimCards(rows), [rows]); - const pending = pendingAmount(rows); - const risk = unverified(rows); - - const companyScoped = useMemo( - () => companyRowsForStats(rows, dimFilter), - [rows, dimFilter], - ); - - /** 客户归属表:无维度筛时看全量归属;有维度筛时只看对应我司成本行 */ - const customerSource = useMemo(() => { - if (!dimFilter) return rows; - return companyScoped; - }, [rows, dimFilter, companyScoped]); - - const siteMonthRows = useMemo( - () => stationMonthAgg(companyScoped), - [companyScoped], - ); - const customerRows = useMemo( - () => customerAttrAgg(customerSource), - [customerSource], - ); - - const detailRows = useMemo(() => { - let list = companyScoped; - if (stationId) list = list.filter((r) => r.stationId === stationId); - if (customerId) list = list.filter((r) => r.customerId === customerId); - return list; - }, [companyScoped, stationId, customerId]); - - const externalEmpty = fleetScope === "external" && rows.length === 0; - const handleRefreshData = () => { - setLiveReload((value) => value + 1); - }; - - return ( -
- - -
-
-
- 期初校准中:期初余额与成本单价未锁定前,本看板仅供内部核对,不作对外真源。 -
-
- {boardScope === "station" ? ( -
-
羚牛氢能 BI / 氢能
-

氢能经营看板

-
- ) : null} - {boardScope === "global" ? ( - - 📅 {timeRangeLabel}:{timeRangeText} - - ) : null} -
-
-
- - -
- {boardScope === "global" ? ( -
- - -
- ) : null} -
-
- - {boardScope === "station" ? ( - - ) : ( - <> - {hostView === "daily" ? ( - - ) : ( - <> - {/* 总览视角筛选条 (包含年份选择、核对筛选、车辆归属及刷新,样式与按日视角全面对齐) */} -
-
-
- { - setYear(y); - clearEntity(); - }} - /> -
- - -
-
- -
-
- - - -
- - - 最后刷新:{lastRefreshedAt} - - - -
-
-
- - {liveOverviewError ? ( -
- 数据接口暂不可用 - 本页未展示任何业务数据,请检查后端服务后重试。 - -
- ) : !liveOverview ? ( -
- - 正在读取真实氢能数据 - 加载完成前不展示业务数值 -
- ) : ( - <> -
-
- } - tone="blue" - label="累计加氢量" - value={hostKpi.totalKgT} - unit="T" - details={[ - { label: "我司承担", value: `${hostKpi.companyKgT} T` }, - { label: "客户承担", value: `${hostKpi.customerKgT} T` }, - { label: "其他", value: `${hostKpi.otherKgT} T` }, - ]} - onClick={() => setKpiDrillType("累计加氢量")} - /> - } - tone="blue" - label="累计成本金额" - prefix="¥" - value={hostKpi.totalCostWan} - unit="万" - details={[ - { label: "我司承担", value: `¥${hostKpi.companyCostWan} 万` }, - { label: "客户承担", value: `¥${hostKpi.customerCostWan} 万` }, - { label: "其他", value: `¥${hostKpi.otherCostWan} 万` }, - ]} - onClick={() => setKpiDrillType("累计成本金额")} - /> - } - tone="green" - label="加氢利润" - prefix="¥" - value={hostKpi.profitWan} - unit="万" - left={`对客 ¥${hostKpi.customerAmountWan} 万`} - right={`成本 ¥${hostKpi.customerCostWan} 万`} - onClick={() => setKpiDrillType("加氢利润")} - /> - } - tone="amber" - label="本月加氢" - value={hostKpi.monthKgT} - unit="T" - left={`加氢费 ¥${hostKpi.monthFeeWan} 万`} - right={`占年比 ${hostKpi.monthYearPct}%`} - onClick={() => setKpiDrillType("本月加氢")} - /> - } - tone="purple" - label="本日加氢" - value={hostKpi.dayKg} - unit="Kg" - left={`加氢费 ¥${hostKpi.dayFee.toLocaleString("zh-CN")}`} - right={`占月比 ${hostKpi.dayMonthPct}%`} - onClick={() => setKpiDrillType("本日加氢")} - /> -
- -
-
-
- -
-
-
- 本月加氢占比 -
-
- {hostKpi.monthYearPct > 0 - ? `占全年 ${hostKpi.monthYearPct}%` - : "—"} -
-
- {monthFluctuationDesc} -
-
-
- -
setStationRankOpen((v) => !v)} - style={{ cursor: "pointer" }} - title="点击查看加氢站加氢量排名" - > -
- -
-
-
头部加氢站占比
-
- Top5 {top5SharePct}% -
-
- 累计 {hostKpi.totalKgT} T · 点击展开加氢量排名 -
-
- - {stationRankOpen && ( -
e.stopPropagation()} - role="listbox" - aria-label="加氢站加氢量排名" - > -
- 加氢站加氢量排名 - - 高 → 低 · 共 {stationRankList.length} 站 - -
-
- {stationRankList.map((st) => ( - - ))} - {stationRankList.length === 0 && ( -
- 当前筛选下暂无站点数据 -
- )} -
-
- )} -
- -
-
- -
-
-
加氢利润率
-
= 0 ? "is-pos" : "is-neg"}`} - > - {hostKpi.profitRatePct}% -
-
- 客户承担:对客总价 {hostKpi.customerAmountWan} 万 − - 成本总价 {hostKpi.customerCostWan} 万 -
-
-
-
-
- - {/* 经营趋势图表大盘:月度加氢量、月度收支对比、Top5站加氢量、各区域加氢占比 */} - setKpiDrillType(lbl)} - onOpenCustomerBill={(custName) => - setSelectedBillCustomer(custName) - } - onOpenStationBill={(stName, prov, id) => - setSelectedStationForDrill({ - id, - name: stName, - province: prov, - }) - } - /> - - )} - - )} - - )} - - {/* KPI 点击下钻数据来源穿透 Modal */} - {boardScope === "global" && kpiDrillType && ( - setKpiDrillType(null)} - /> - )} - - {/* 客户账单专属下钻 Modal (客户 → 日期 → 车牌加氢记录) */} - {boardScope === "global" && selectedBillCustomer && ( - setSelectedBillCustomer(null)} - /> - )} - - {/* 加氢站账单专属下钻 Modal (加氢站 → 所有日期的加氢量、占比、氢费收入、收入占比) */} - {boardScope === "global" && selectedStationForDrill && ( - setSelectedStationForDrill(null)} - /> - )} -
-
- ); -}; - -function PlugZapHint() { - return ; -} - -function HostKpi({ - icon, - tone, - label, - value, - prefix, - unit, - left, - right, - details, - onClick, -}: { - icon: React.ReactNode; - tone: "blue" | "green" | "amber" | "purple" | "cyan"; - label: string; - value: React.ReactNode; - prefix?: string; - unit?: string; - left?: string; - right?: string; - details?: Array<{ label: string; value: string }>; - onClick?: () => void; -}) { - return ( - - ); -} - -/** KPI 数据来源穿透 & 站 -> 客户 -> 车牌三级下钻 Modal 弹窗 */ -interface KpiDrillModalProps { - label: string; - year: number; - fleetScope: FleetScope; - verifyScope: "all" | "verified"; - onClose: () => void; -} - -function makeVehicleOrders( - certPrefix: string, - fleetCategory: FleetCategory, - mode: "verified" | "unverified" | "partial", - totalKg: number, -) { - const isOwn = fleetCategory === "own"; - const unitPrice = 4.5; - const list = [ - { time: "2026-08-08 09:15", factor: 1.2, source: "api" as const }, - { time: "2026-08-08 14:30", factor: 0.9, source: "api" as const }, - { - time: "2026-08-07 11:20", - factor: 1.1, - source: "station_report" as const, - }, - { - time: "2026-08-06 16:45", - factor: 0.8, - source: "lingniu_report" as const, - }, - { time: "2026-08-05 10:10", factor: 1.05, source: "api" as const }, - { - time: "2026-08-04 15:25", - factor: 0.95, - source: "station_report" as const, - }, - { time: "2026-08-03 08:50", factor: 1.15, source: "api" as const }, - { - time: "2026-08-02 17:05", - factor: 0.85, - source: "lingniu_report" as const, - }, - { - time: "2026-08-01 12:40", - factor: 1.0, - source: "station_report" as const, - }, - { time: "2026-07-31 09:30", factor: 0.9, source: "api" as const }, - ]; - - return list.map((item, idx) => { - const seq = String(idx + 1).padStart(2, "0"); - const kg = Math.round((totalKg / 100) * item.factor * 10) / 10; - const certNo = - item.source === "api" - ? `API-20260808-${certPrefix}-${seq}` - : item.source === "station_report" - ? `ST-20260808-${certPrefix}-${seq}` - : `LN-20260808-${certPrefix}-${seq}`; - - let verifyStatus: "verified" | "unverified" | null = null; - if (isOwn) { - if (mode === "verified") verifyStatus = "verified"; - else if (mode === "unverified") verifyStatus = "unverified"; - else { - verifyStatus = idx % 2 === 0 ? "verified" : "unverified"; - } - } - - return { - orderId: `ORD-20260808-${certPrefix}-${seq}`, - time: item.time, - kg, - unitPrice, - amount: Math.round(kg * unitPrice), - source: item.source, - certNo, - verifyStatus, - }; - }); -} - -function computeVehicleVerifyStatus( - orders: { verifyStatus?: "verified" | "unverified" | null }[], - fleetCategory: FleetCategory, -): "verified" | "unverified" | "partial" | null { - if (fleetCategory !== "own") return null; - if (!orders || orders.length === 0) return "unverified"; - const verifiedCount = orders.filter( - (o) => o.verifyStatus === "verified", - ).length; - const unverifiedCount = orders.filter( - (o) => o.verifyStatus === "unverified", - ).length; - if (verifiedCount > 0 && unverifiedCount > 0) return "partial"; - if (verifiedCount > 0 && unverifiedCount === 0) return "verified"; - return "unverified"; -} - -/** 站/客户层:按下属车辆核对态汇总。全已核→已核对;全未核→未核对;有混杂或任一带部分→部分核对。外部车不参与。 */ -function aggregateVehiclesVerifyStatus( - vehicles: { - orders: { verifyStatus?: "verified" | "unverified" | null }[]; - fleetCategory: FleetCategory; - plateNo?: string; - }[], -): "verified" | "unverified" | "partial" | null { - const statuses = vehicles - .map((vh) => computeVehicleVerifyStatus(vh.orders, vh.fleetCategory)) - .filter((s): s is "verified" | "unverified" | "partial" => s !== null); - if (statuses.length === 0) return null; - if (statuses.every((s) => s === "verified")) return "verified"; - if (statuses.every((s) => s === "unverified")) return "unverified"; - return "partial"; -} - -/** 穿透表标签悬浮说明 */ -const FLEET_TAG_TIP = { - own: "羚牛车辆:车牌可识别,且归属羚牛自有/合作车队", - external: "外部车辆:非羚牛车队;无法识别车牌的归入「无车牌」并标外部车辆", -} as const; - -const SOURCE_TAG_TIP: Record< - "api" | "station_report" | "lingniu_report", - string -> = { - api: "API接入:加氢数据由接口自动归集,可按接口流水追溯", - station_report: "站点上报:由加氢站报送的加氢数据", - lingniu_report: "羚牛上报:从 OneOS 归集的加氢记录", -}; - -const VERIFY_TAG_TIP = { - verified: "已核对:范围内加氢订单均已完成核对", - partial: "部分核对:范围内既有已核对,也有未核对订单", - unverified: "未核对:范围内加氢订单均尚未核对", - order_verified: "已核对:该笔加氢订单已完成核对", - order_unverified: "未核对:该笔加氢订单尚未核对", - external_skip: "外部车辆不参与核对,故无核对状态", -} as const; - -function renderFleetTag(isOwnFleet: boolean) { - return ( - - {isOwnFleet ? "羚牛车辆" : "外部车辆"} - - ); -} - -function renderSourceTag( - source: "api" | "station_report" | "lingniu_report" | string, -) { - const key = - source === "api" || - source === "station_report" || - source === "lingniu_report" - ? source - : "api"; - const mod = - key === "api" ? "api" : key === "station_report" ? "station" : "lingniu"; - return ( - - {SOURCE_TYPE_LABEL[key] || source} - - ); -} - -function renderOrderVerifyTag( - fleetCategory: FleetCategory, - verifyStatus: "verified" | "unverified" | null | undefined, -) { - if (fleetCategory !== "own") { - return ( - - - - - ); - } - if (verifyStatus === "verified") { - return ( - - 已核对 - - ); - } - return ( - - 未核对 - - ); -} - -function renderVehicleVerifyTag( - fleetCategory: FleetCategory, - status: "verified" | "unverified" | "partial" | null, -) { - if (fleetCategory !== "own" || status === null) { - return ( - - - - - ); - } - if (status === "verified") { - return ( - - 已核对 - - ); - } - if (status === "partial") { - return ( - - 部分核对 - - ); - } - return ( - - 未核对 - - ); -} - -function renderAggVerifyTag( - status: "verified" | "unverified" | "partial" | null, - titlePrefix = "下属车辆", -) { - if (status === "verified") { - return ( - - 已核对 - - ); - } - if (status === "partial") { - return ( - - 部分核对 - - ); - } - if (status === "unverified") { - return ( - - 未核对 - - ); - } - return ( - - - - - ); -} - -function KpiDrillModal({ - label, - year, - fleetScope, - verifyScope, - onClose, -}: KpiDrillModalProps) { - const [stationFilter, setStationFilter] = useState("all"); - const [customerFilter, setCustomerFilter] = useState("all"); - const [plateFilter, setPlateFilter] = useState("all"); - const [fleetCategoryFilter, setFleetCategoryFilter] = useState< - "all" | "own" | "external" - >(fleetScope); - - // 跟随顶栏车辆筛选:打开/切换时同步 - useEffect(() => { - setFleetCategoryFilter(fleetScope); - }, [fleetScope, label]); - - // 展开折叠状态 - const [expandedStations, setExpandedStations] = useState< - Record - >({ - "st-jx": true, // 默认展开嘉兴站 - }); - const [expandedCustomers, setExpandedCustomers] = useState< - Record - >({ - "st-jx_c-zp": true, // 默认展开乍浦港口客户 - }); - const [expandedVehicles, setExpandedVehicles] = useState< - Record - >({ - "st-jx_c-zp_浙F88888": true, // 默认展开首辆车,展现单笔订单与部分核对明细 - }); - const [expandedAllVehicleOrders, setExpandedAllVehicleOrders] = useState< - Record - >({}); - - const toggleStation = (stId: string) => { - setExpandedStations((prev) => ({ ...prev, [stId]: !prev[stId] })); - }; - - const toggleCustomer = (stId: string, custId: string) => { - const key = `${stId}_${custId}`; - setExpandedCustomers((prev) => ({ ...prev, [key]: !prev[key] })); - }; - - const toggleVehicle = (stId: string, custId: string, plateNo: string) => { - const key = `${stId}_${custId}_${plateNo}`; - setExpandedVehicles((prev) => ({ ...prev, [key]: !prev[key] })); - }; - - const toggleShowAllOrders = (vhKey: string) => { - setExpandedAllVehicleOrders((prev) => ({ ...prev, [vhKey]: !prev[vhKey] })); - }; - - // 站 -> 客户 -> 车牌 & 接口凭证 高保真全链路数据 - const drillStations = useMemo(() => { - const is2026 = year === 2026; - const factor = is2026 ? 1 : 0.85; - - return [ - { - stationId: "st-jx", - stationName: "嘉兴中石化滨海加氢站", - stationType: "self_use" as const, - province: "浙江省", - totalKg: Math.round(243661 * factor), - totalFeeWan: (1096.47 * factor).toFixed(2), - customers: [ - { - customerId: "c-zp", - customerName: "嘉兴市乍浦港口经营有限公司", - category: "internal" as const, - totalKg: Math.round(163250 * factor), - totalFeeWan: (734.63 * factor).toFixed(2), - vehicles: [ - { - plateNo: "浙F88888", - fleetCategory: "own" as const, - source: "api" as const, - certNo: "API-20260808-9821", - count: 142, - kg: 42600, - amount: 191700, - orders: makeVehicleOrders("9821", "own", "partial", 42600), - }, - { - plateNo: "浙F66666", - fleetCategory: "own" as const, - source: "api" as const, - certNo: "API-20260808-9822", - count: 128, - kg: 38400, - amount: 172800, - orders: makeVehicleOrders("9822", "own", "verified", 38400), - }, - { - plateNo: "浙F77777", - fleetCategory: "own" as const, - source: "lingniu_report" as const, - certNo: "LN-REP-202608-012", - count: 110, - kg: 33000, - amount: 148500, - orders: makeVehicleOrders("012", "own", "verified", 33000), - }, - { - plateNo: "浙F55555", - fleetCategory: "own" as const, - source: "station_report" as const, - certNo: "ST-REP-202608-045", - count: 98, - kg: 29400, - amount: 132300, - orders: makeVehicleOrders("045", "own", "unverified", 29400), - }, - { - plateNo: "浙F33333", - fleetCategory: "own" as const, - source: "api" as const, - certNo: "API-20260808-9825", - count: 66, - kg: 19850, - amount: 89325, - orders: makeVehicleOrders("9825", "own", "partial", 19850), - }, - ], - }, - { - customerId: "c-ys", - customerName: "嘉兴益顺冷链物流有限公司", - category: "external" as const, - totalKg: Math.round(54869 * factor), - totalFeeWan: (246.91 * factor).toFixed(2), - vehicles: [ - { - plateNo: "浙F12345", - fleetCategory: "external" as const, - source: "api" as const, - certNo: "API-20260808-7712", - count: 85, - kg: 25500, - amount: 114750, - orders: makeVehicleOrders( - "7712", - "external", - "verified", - 25500, - ), - }, - { - plateNo: "浙F67890", - fleetCategory: "external" as const, - source: "station_report" as const, - certNo: "ST-REP-202608-088", - count: 62, - kg: 18600, - amount: 83700, - orders: makeVehicleOrders("088", "external", "verified", 18600), - }, - { - plateNo: "无车牌", - fleetCategory: "external" as const, - source: "station_report" as const, - certNo: "ST-REP-202608-099", - count: 36, - kg: 10769, - amount: 48460, - orders: makeVehicleOrders("099", "external", "verified", 10769), - }, - ], - }, - { - customerId: "c-zq", - customerName: "嘉兴智奇供应链管理有限公司", - category: "external" as const, - totalKg: Math.round(25542 * factor), - totalFeeWan: (114.93 * factor).toFixed(2), - vehicles: [ - { - plateNo: "沪A99881", - fleetCategory: "external" as const, - source: "api" as const, - certNo: "API-20260808-6623", - count: 52, - kg: 15600, - amount: 70200, - orders: makeVehicleOrders( - "6623", - "external", - "verified", - 15600, - ), - }, - { - plateNo: "沪A99882", - fleetCategory: "external" as const, - source: "api" as const, - certNo: "API-20260808-6624", - count: 33, - kg: 9942, - amount: 44739, - orders: makeVehicleOrders("6624", "external", "verified", 9942), - }, - ], - }, - ], - }, - { - stationId: "st-jj", - stationName: "嘉兴嘉锦加氢站", - stationType: "external_sale" as const, - province: "浙江省", - totalKg: Math.round(182889 * factor), - totalFeeWan: (823.0 * factor).toFixed(2), - customers: [ - { - customerId: "c-ln", - customerName: "羚牛自营车队", - category: "internal" as const, - totalKg: Math.round(128020 * factor), - totalFeeWan: (576.09 * factor).toFixed(2), - vehicles: [ - { - plateNo: "浙A88881F", - fleetCategory: "own" as const, - source: "api" as const, - certNo: "API-20260808-1001", - count: 180, - kg: 54000, - amount: 243000, - orders: makeVehicleOrders("1001", "own", "verified", 54000), - }, - { - plateNo: "浙A88882F", - fleetCategory: "own" as const, - source: "lingniu_report" as const, - certNo: "LN-REP-202608-102", - count: 150, - kg: 45000, - amount: 202500, - orders: makeVehicleOrders("102", "own", "verified", 45000), - }, - { - plateNo: "浙A88883F", - fleetCategory: "own" as const, - source: "station_report" as const, - certNo: "ST-REP-202608-103", - count: 96, - kg: 29020, - amount: 130590, - orders: makeVehicleOrders("103", "own", "unverified", 29020), - }, - ], - }, - { - customerId: "c-qb", - customerName: "四川群彬物流有限公司", - category: "external" as const, - totalKg: Math.round(54869 * factor), - totalFeeWan: (246.91 * factor).toFixed(2), - vehicles: [ - { - plateNo: "川A77123", - fleetCategory: "external" as const, - source: "api" as const, - certNo: "API-20260808-3301", - count: 110, - kg: 33000, - amount: 148500, - orders: makeVehicleOrders( - "3301", - "external", - "verified", - 33000, - ), - }, - { - plateNo: "川A77124", - fleetCategory: "external" as const, - source: "station_report" as const, - certNo: "ST-REP-202608-332", - count: 72, - kg: 21869, - amount: 98410, - orders: makeVehicleOrders("332", "external", "verified", 21869), - }, - ], - }, - ], - }, - { - stationId: "st-jr", - stationName: "嘉兴嘉燃加氢站", - stationType: "self_use" as const, - province: "浙江省", - totalKg: Math.round(28234 * factor), - totalFeeWan: (127.05 * factor).toFixed(2), - customers: [ - { - customerId: "c-yj", - customerName: "浙江洋井供应链管理有限公司", - category: "external" as const, - totalKg: Math.round(28234 * factor), - totalFeeWan: (127.05 * factor).toFixed(2), - vehicles: [ - { - plateNo: "浙B99112", - fleetCategory: "external" as const, - source: "api" as const, - certNo: "API-20260808-4411", - count: 60, - kg: 18350, - amount: 82575, - orders: makeVehicleOrders( - "4411", - "external", - "verified", - 18350, - ), - }, - { - plateNo: "浙B99113", - fleetCategory: "external" as const, - source: "api" as const, - certNo: "API-20260808-4412", - count: 32, - kg: 9884, - amount: 44478, - orders: makeVehicleOrders("4412", "external", "verified", 9884), - }, - ], - }, - ], - }, - { - stationId: "st-ln", - stationName: "桐乡中石化绿能加氢站", - stationType: "external_sale" as const, - province: "浙江省", - totalKg: Math.round(26080 * factor), - totalFeeWan: (117.36 * factor).toFixed(2), - customers: [ - { - customerId: "c-js", - customerName: "重庆金时源供应链有限公司", - category: "external" as const, - totalKg: Math.round(26080 * factor), - totalFeeWan: (117.36 * factor).toFixed(2), - vehicles: [ - { - plateNo: "渝A66881", - fleetCategory: "external" as const, - source: "api" as const, - certNo: "API-20260808-5501", - count: 52, - kg: 15648, - amount: 70416, - orders: makeVehicleOrders( - "5501", - "external", - "verified", - 15648, - ), - }, - { - plateNo: "渝A66882", - fleetCategory: "external" as const, - source: "station_report" as const, - certNo: "ST-REP-202608-552", - count: 35, - kg: 10432, - amount: 46944, - orders: makeVehicleOrders("552", "external", "verified", 10432), - }, - ], - }, - ], - }, - { - stationId: "st-tf", - stationName: "成都中石化天府机场北站", - stationType: "external_sale" as const, - province: "四川省", - totalKg: Math.round(22929 * factor), - totalFeeWan: (103.18 * factor).toFixed(2), - customers: [ - { - customerId: "c-gz", - customerName: "四川拱照物流有限公司", - category: "external" as const, - totalKg: Math.round(22929 * factor), - totalFeeWan: (103.18 * factor).toFixed(2), - vehicles: [ - { - plateNo: "川A88901", - fleetCategory: "external" as const, - source: "api" as const, - certNo: "API-20260808-8801", - count: 53, - kg: 16050, - amount: 72225, - orders: makeVehicleOrders( - "8801", - "external", - "verified", - 16050, - ), - }, - { - plateNo: "川A88902", - fleetCategory: "external" as const, - source: "station_report" as const, - certNo: "ST-REP-202608-8802", - count: 23, - kg: 6879, - amount: 30955, - orders: makeVehicleOrders("8802", "external", "verified", 6879), - }, - ], - }, - ], - }, - ]; - }, [year]); - - // 按条件过滤(站/客户/车牌可搜索选择 + 车辆归属 + 顶栏核对范围) - const filteredStations = useMemo(() => { - return drillStations - .map((st) => { - if (stationFilter !== "all" && st.stationId !== stationFilter) - return null; - - const filteredCustomers = st.customers - .map((cust) => { - if (customerFilter !== "all" && cust.customerId !== customerFilter) - return null; - - const filteredVehicles = cust.vehicles - .map((vh) => { - if ( - fleetCategoryFilter !== "all" && - vh.fleetCategory !== fleetCategoryFilter - ) - return null; - if (plateFilter !== "all" && vh.plateNo !== plateFilter) - return null; - - if (verifyScope === "verified") { - // 仅已核对:外部车不参与核对;羚牛车只保留已核订单 - if (vh.fleetCategory !== "own") return null; - const orders = vh.orders.filter( - (o) => o.verifyStatus === "verified", - ); - if (orders.length === 0) return null; - const kg = orders.reduce((s, o) => s + o.kg, 0); - const amount = orders.reduce((s, o) => s + o.amount, 0); - return { ...vh, orders, count: orders.length, kg, amount }; - } - return vh; - }) - .filter(Boolean) as typeof cust.vehicles; - - if (filteredVehicles.length === 0) return null; - return { - ...cust, - vehicles: filteredVehicles, - totalKg: filteredVehicles.reduce((sum, v) => sum + v.kg, 0), - totalFeeWan: ( - filteredVehicles.reduce((sum, v) => sum + v.amount, 0) / 10000 - ).toFixed(2), - }; - }) - .filter(Boolean) as typeof st.customers; - - if (filteredCustomers.length === 0) return null; - - return { - ...st, - customers: filteredCustomers, - totalKg: filteredCustomers.reduce((sum, c) => sum + c.totalKg, 0), - totalFeeWan: filteredCustomers - .reduce((sum, c) => sum + parseFloat(c.totalFeeWan), 0) - .toFixed(2), - }; - }) - .filter(Boolean) as typeof drillStations; - }, [ - drillStations, - stationFilter, - customerFilter, - plateFilter, - fleetCategoryFilter, - verifyScope, - ]); - - // 级联选项:站 → 客户 → 车牌(选项池随上级选择收窄) - const stationOptions = useMemo( - () => - drillStations.map((st) => ({ - value: st.stationId, - label: st.stationName, - })), - [drillStations], - ); - - const customerOptions = useMemo(() => { - const map = new Map(); - drillStations.forEach((st) => { - if (stationFilter !== "all" && st.stationId !== stationFilter) return; - st.customers.forEach((c) => { - if (!map.has(c.customerId)) map.set(c.customerId, c.customerName); - }); - }); - return Array.from(map.entries()).map(([value, label]) => ({ - value, - label, - })); - }, [drillStations, stationFilter]); - - const plateOptions = useMemo(() => { - const set = new Set(); - drillStations.forEach((st) => { - if (stationFilter !== "all" && st.stationId !== stationFilter) return; - st.customers.forEach((c) => { - if (customerFilter !== "all" && c.customerId !== customerFilter) return; - c.vehicles.forEach((vh) => { - if ( - fleetCategoryFilter !== "all" && - vh.fleetCategory !== fleetCategoryFilter - ) - return; - set.add(vh.plateNo); - }); - }); - }); - return Array.from(set).map((plate) => ({ - value: plate, - label: /无车牌/.test(plate) ? "无车牌" : plate, - })); - }, [drillStations, stationFilter, customerFilter, fleetCategoryFilter]); - - // 上级变更时清掉下级无效选中 - useEffect(() => { - if ( - customerFilter !== "all" && - !customerOptions.some((o) => o.value === customerFilter) - ) { - setCustomerFilter("all"); - setPlateFilter("all"); - } - }, [customerOptions, customerFilter]); - - useEffect(() => { - if ( - plateFilter !== "all" && - !plateOptions.some((o) => o.value === plateFilter) - ) { - setPlateFilter("all"); - } - }, [plateOptions, plateFilter]); - - // KPI 穿透列口径:默认量/金额;加氢利润→收入/成本/利润;本月→月量/月费/占年比;本日→日量/日费/占月比;月度柱→站×内外部客户量;收支柱→站×收入/成本 - const isProfitDrill = label === "加氢利润"; - const isMonthDrill = label === "本月加氢"; - const isDayDrill = label === "本日加氢"; - const monthMetricMatch = label.match( - /^(\d{4})年(\d{1,2})月(加氢量|客户收入|成本支出)$/, - ); - const isMonthBarDrill = monthMetricMatch?.[3] === "加氢量"; - const isMonthIncomeDrill = monthMetricMatch?.[3] === "客户收入"; - const isMonthCostDrill = monthMetricMatch?.[3] === "成本支出"; - const isStationMonthFlat = - isMonthBarDrill || isMonthIncomeDrill || isMonthCostDrill; - const stationCustMatch = label.match(/^加氢站(?:客户量)?:(.+)$/); - const isStationCustomerDrill = Boolean(stationCustMatch); - const stationCustTarget = stationCustMatch?.[1]?.trim() ?? ""; - const regionMatch = label.match(/^区域(市|省):(.+)$/); - const isRegionDrill = Boolean(regionMatch); - const regionKind = regionMatch?.[1] as "市" | "省" | undefined; - const regionLabel = regionMatch?.[2]?.trim() ?? ""; - const isFlatOverviewDrill = - isStationMonthFlat || isStationCustomerDrill || isRegionDrill; - const monthBarIndex = monthMetricMatch ? Number(monthMetricMatch[2]) - 1 : -1; - const MONTH_BAR_KG = [ - 85200, 52000, 112800, 135000, 128000, 118000, 122000, 28000, - ]; - const monthBarYearKg = MONTH_BAR_KG.reduce((s, n) => s + n, 0) || 1; - const monthBarShare = - monthBarIndex >= 0 && monthBarIndex < MONTH_BAR_KG.length - ? MONTH_BAR_KG[monthBarIndex] / monthBarYearKg - : 1; - const incomeRatio = HOST_KPI.incomeWan / (HOST_KPI.costWan || 1); - const profitRatio = HOST_KPI.profitWan / (HOST_KPI.costWan || 1); - const monthShare = HOST_KPI.monthKgT / (HOST_KPI.totalKgT || 1); - const dayShare = HOST_KPI.dayKg / ((HOST_KPI.totalKgT || 1) * 1000); - const toIncomeYuan = (costYuan: number) => - Math.round(costYuan * incomeRatio * 100) / 100; - const toProfitYuan = (costYuan: number) => - Math.round(costYuan * profitRatio * 100) / 100; - const toMonthKg = (kg: number) => Math.round(kg * monthShare * 100) / 100; - const toMonthFee = (yuan: number) => - Math.round(yuan * monthShare * 100) / 100; - const toDayKg = (kg: number) => Math.round(kg * dayShare * 100) / 100; - const toDayFee = (yuan: number) => Math.round(yuan * dayShare * 100) / 100; - const feeWanToYuan = (wan: string | number) => - parseFloat(String(wan)) * 10000; - const filteredYearFeeYuan = filteredStations.reduce( - (s, st) => s + feeWanToYuan(st.totalFeeWan), - 0, - ); - const filteredMonthFeeYuan = toMonthFee(filteredYearFeeYuan); - const toFeeYearPct = (monthFeeYuan: number) => - filteredYearFeeYuan > 0 - ? Math.round((monthFeeYuan / filteredYearFeeYuan) * 10000) / 100 - : 0; - const toFeeMonthPct = (dayFeeYuan: number) => - filteredMonthFeeYuan > 0 - ? Math.round((dayFeeYuan / filteredMonthFeeYuan) * 10000) / 100 - : 0; - const colCount = isProfitDrill || isMonthDrill || isDayDrill ? 8 : 7; - - /** 月度柱钻取:各站内部/外部客户加氢量 + 合计(按当月占年份额缩放) */ - const stationMonthRows = useMemo(() => { - return filteredStations - .map((st) => { - const internalKg = st.customers - .filter((c) => c.category === "internal") - .reduce((s, c) => s + c.totalKg, 0); - const externalKg = st.customers - .filter((c) => c.category === "external") - .reduce((s, c) => s + c.totalKg, 0); - return { - stationId: st.stationId, - stationName: st.stationName, - province: st.province, - internalKg: Math.round(internalKg * monthBarShare), - externalKg: Math.round(externalKg * monthBarShare), - totalKg: Math.round((internalKg + externalKg) * monthBarShare), - }; - }) - .sort((a, b) => b.totalKg - a.totalKg); - }, [filteredStations, monthBarShare]); - - /** 月度收支柱钻取:各站客户收入 / 成本支出 */ - const stationRevRows = useMemo(() => { - return filteredStations - .map((st) => { - const monthFee = feeWanToYuan(st.totalFeeWan) * monthBarShare; - return { - stationId: st.stationId, - stationName: st.stationName, - province: st.province, - incomeYuan: Math.round(monthFee * incomeRatio), - costYuan: Math.round(monthFee), - }; - }) - .sort((a, b) => - isMonthCostDrill - ? b.costYuan - a.costYuan - : b.incomeYuan - a.incomeYuan, - ); - }, [filteredStations, monthBarShare, incomeRatio, isMonthCostDrill]); - - const monthBarInternalSum = stationMonthRows.reduce( - (s, r) => s + r.internalKg, - 0, - ); - const monthBarExternalSum = stationMonthRows.reduce( - (s, r) => s + r.externalKg, - 0, - ); - const monthBarTotalSum = stationMonthRows.reduce((s, r) => s + r.totalKg, 0); - const monthIncomeSum = stationRevRows.reduce((s, r) => s + r.incomeYuan, 0); - const monthCostSum = stationRevRows.reduce((s, r) => s + r.costYuan, 0); - - /** Top5 / 站名钻取:该站内部客户 vs 外部客户加氢总量 */ - const stationCustomerRows = useMemo(() => { - if (!isStationCustomerDrill || !stationCustTarget) return []; - const st = - drillStations.find((s) => s.stationName === stationCustTarget) || - drillStations.find( - (s) => - s.stationName.includes(stationCustTarget.replace(/加氢站$/, "")) || - stationCustTarget.includes(s.stationName.replace(/加氢站$/, "")), - ); - if (!st) { - const top = [ - { rank: 1, name: "嘉兴中石化滨海加氢站", ownKg: 163250, extKg: 80411 }, - { rank: 2, name: "嘉兴嘉锦加氢站", ownKg: 128020, extKg: 54869 }, - { rank: 3, name: "嘉兴嘉燃加氢站", ownKg: 18350, extKg: 9884 }, - { rank: 4, name: "桐乡中石化绿能加氢站", ownKg: 15648, extKg: 10432 }, - { rank: 5, name: "成都中石化天府机场北站", ownKg: 16050, extKg: 6879 }, - ].find( - (t) => - t.name === stationCustTarget || - stationCustTarget.includes(t.name.slice(0, 6)), - ); - if (!top) return []; - return [ - { - customerId: "own", - customerName: "内部客户合计", - category: "internal" as const, - totalKg: top.ownKg, - }, - { - customerId: "ext", - customerName: "外部客户合计", - category: "external" as const, - totalKg: top.extKg, - }, - ]; - } - return st.customers - .map((c) => ({ - customerId: c.customerId, - customerName: c.customerName, - category: c.category, - totalKg: c.totalKg, - })) - .sort((a, b) => b.totalKg - a.totalKg); - }, [drillStations, isStationCustomerDrill, stationCustTarget]); - - const stationCustInternalKg = stationCustomerRows - .filter((r) => r.category === "internal") - .reduce((s, r) => s + r.totalKg, 0); - const stationCustExternalKg = stationCustomerRows - .filter((r) => r.category === "external") - .reduce((s, r) => s + r.totalKg, 0); - const stationCustTotalKg = stationCustInternalKg + stationCustExternalKg; - - /** 区域(市/省)钻取:区域内各站加氢总量与占比 */ - const regionStationRows = useMemo(() => { - if (!isRegionDrill || !regionLabel) return []; - const CITY_KEYS: Record = { - 嘉兴市: ["嘉兴", "桐乡", "嘉善"], - 成都市: ["成都"], - 佛山市: ["佛山"], - 昆山市: ["昆山"], - 常熟市: ["常熟"], - 广州市: ["广州"], - 深圳市: ["深圳"], - 无锡市: ["无锡"], - }; - const matchCity = (name: string, city: string) => { - if (city === "其他城市") { - const keys = Object.values(CITY_KEYS).flat(); - return !keys.some((k) => name.includes(k)); - } - const keys = CITY_KEYS[city] || [city.replace(/市$/, "")]; - return keys.some((k) => name.includes(k)); - }; - const list = MOCK_STATION_SUMMARY_LIST.filter((st) => { - if (regionKind === "省") { - if (regionLabel === "其他省份") { - return !["浙江省", "四川省", "广东省", "江苏省"].includes( - st.province, - ); - } - return st.province === regionLabel; - } - return matchCity(st.name, regionLabel); - }); - const totalKg = - list.reduce((s, st) => s + parseFloat(st.kgT) * 1000, 0) || 1; - return list - .map((st) => { - const kg = Math.round(parseFloat(st.kgT) * 1000); - return { - name: st.name, - province: st.province, - kg, - kgT: st.kgT, - pct: Math.round((kg / totalKg) * 10000) / 100, - incomeWan: st.incomeWan, - }; - }) - .sort((a, b) => b.kg - a.kg); - }, [isRegionDrill, regionKind, regionLabel]); - - const regionKgSum = regionStationRows.reduce((s, r) => s + r.kg, 0); - - const renderMetricCells = (kg: number, feeYuan: number) => { - if (isProfitDrill) { - return ( - <> - - ¥{toIncomeYuan(feeYuan).toLocaleString("zh-CN")} - - - ¥{feeYuan.toLocaleString("zh-CN")} - - - ¥{toProfitYuan(feeYuan).toLocaleString("zh-CN")} - - - ); - } - if (isMonthDrill) { - const mKg = toMonthKg(kg); - const mFee = toMonthFee(feeYuan); - return ( - <> - - {mKg.toLocaleString("zh-CN")} Kg - - - ¥{mFee.toLocaleString("zh-CN")} - - - {toFeeYearPct(mFee).toFixed(2)}% - - - ); - } - if (isDayDrill) { - const dKg = toDayKg(kg); - const dFee = toDayFee(feeYuan); - return ( - <> - - {dKg.toLocaleString("zh-CN")} Kg - - - ¥{dFee.toLocaleString("zh-CN")} - - - {toFeeMonthPct(dFee).toFixed(2)}% - - - ); - } - return ( - <> - - {kg.toLocaleString("zh-CN")} Kg - - - ¥{feeYuan.toLocaleString("zh-CN")} - - - ); - }; - - const handleExportDrillExcel = () => { - if (isMonthBarDrill) { - const aoa: (string | number)[][] = [ - [ - "加氢站名称", - "所属省份", - "内部客户加氢总量(Kg)", - "外部客户加氢总量(Kg)", - "合计加氢总量(Kg)", - ], - ]; - stationMonthRows.forEach((r) => { - aoa.push([ - r.stationName, - r.province, - r.internalKg, - r.externalKg, - r.totalKg, - ]); - }); - downloadExcelAoa( - aoa, - `${label}_各站内外部客户加氢量.xlsx`, - "月度各站加氢量", - ); - return; - } - if (isMonthIncomeDrill) { - const aoa: (string | number)[][] = [ - ["加氢站名称", "所属省份", "客户收入(元)"], - ]; - stationRevRows.forEach((r) => { - aoa.push([r.stationName, r.province, r.incomeYuan]); - }); - downloadExcelAoa(aoa, `${label}_各站客户收入.xlsx`, "月度各站客户收入"); - return; - } - if (isMonthCostDrill) { - const aoa: (string | number)[][] = [ - ["加氢站名称", "所属省份", "成本支出(元)"], - ]; - stationRevRows.forEach((r) => { - aoa.push([r.stationName, r.province, r.costYuan]); - }); - downloadExcelAoa(aoa, `${label}_各站成本支出.xlsx`, "月度各站成本支出"); - return; - } - if (isStationCustomerDrill) { - const aoa: (string | number)[][] = [ - ["加氢站", "客户名称", "客户类型", "加氢总量(Kg)"], - ]; - stationCustomerRows.forEach((r) => { - aoa.push([ - stationCustTarget, - r.customerName, - r.category === "internal" ? "内部客户" : "外部客户", - r.totalKg, - ]); - }); - downloadExcelAoa( - aoa, - `${stationCustTarget}_内外部客户加氢量.xlsx`, - "站客户加氢量", - ); - return; - } - if (isRegionDrill) { - const aoa: (string | number)[][] = [ - ["区域", "加氢站名称", "所属省份", "加氢总量(Kg)", "区域内占比(%)"], - ]; - regionStationRows.forEach((r) => { - aoa.push([regionLabel, r.name, r.province, r.kg, r.pct]); - }); - downloadExcelAoa( - aoa, - `${regionLabel}_各站加氢总量占比.xlsx`, - "区域各站加氢量", - ); - return; - } - - const aoa: (string | number)[][] = [ - isProfitDrill - ? [ - "加氢站名称", - "客户名称", - "车牌号", - "车辆归属", - "订单编号", - "加氢时间", - "数据来源", - "订单核对状态", - "加氢量(Kg)", - "收入(元)", - "成本(元)", - "利润(元)", - ] - : isMonthDrill - ? [ - "加氢站名称", - "客户名称", - "车牌号", - "车辆归属", - "订单编号", - "加氢时间", - "数据来源", - "订单核对状态", - "本月加氢量(Kg)", - "本月加氢费(元)", - "加氢费占年比(%)", - ] - : isDayDrill - ? [ - "加氢站名称", - "客户名称", - "车牌号", - "车辆归属", - "订单编号", - "加氢时间", - "数据来源", - "订单核对状态", - "本日加氢量(Kg)", - "本日加氢费(元)", - "加氢费占月比(%)", - ] - : [ - "加氢站名称", - "加氢站类型", - "客户名称", - "客户类型", - "车牌号", - "车辆归属", - "订单编号", - "加氢时间", - "数据来源", - "来源凭证/接口流水号", - "订单核对状态", - "单价(元/Kg)", - "加氢量(Kg)", - "加氢金额(元)", - ], - ]; - - drillStations.forEach((st) => { - st.customers.forEach((cust) => { - cust.vehicles.forEach((vh) => { - vh.orders.forEach((ord) => { - if (isProfitDrill) { - aoa.push([ - st.stationName, - cust.customerName, - vh.plateNo, - vh.fleetCategory === "own" ? "羚牛车辆" : "外部车辆", - ord.orderId, - ord.time, - SOURCE_TYPE_LABEL[ord.source] || ord.source, - vh.fleetCategory === "own" - ? DAILY_VERIFY_LABEL[ord.verifyStatus || "unverified"] || - "未核对" - : "-", - ord.kg, - toIncomeYuan(ord.amount), - ord.amount, - toProfitYuan(ord.amount), - ]); - } else if (isMonthDrill) { - const mFee = toMonthFee(ord.amount); - aoa.push([ - st.stationName, - cust.customerName, - vh.plateNo, - vh.fleetCategory === "own" ? "羚牛车辆" : "外部车辆", - ord.orderId, - ord.time, - SOURCE_TYPE_LABEL[ord.source] || ord.source, - vh.fleetCategory === "own" - ? DAILY_VERIFY_LABEL[ord.verifyStatus || "unverified"] || - "未核对" - : "-", - toMonthKg(ord.kg), - mFee, - toFeeYearPct(mFee), - ]); - } else if (isDayDrill) { - const dFee = toDayFee(ord.amount); - aoa.push([ - st.stationName, - cust.customerName, - vh.plateNo, - vh.fleetCategory === "own" ? "羚牛车辆" : "外部车辆", - ord.orderId, - ord.time, - SOURCE_TYPE_LABEL[ord.source] || ord.source, - vh.fleetCategory === "own" - ? DAILY_VERIFY_LABEL[ord.verifyStatus || "unverified"] || - "未核对" - : "-", - toDayKg(ord.kg), - dFee, - toFeeMonthPct(dFee), - ]); - } else { - aoa.push([ - st.stationName, - st.stationType === "self_use" ? "自用消费" : "对外销售", - cust.customerName, - cust.category === "internal" ? "内部客户" : "外部客户", - vh.plateNo, - vh.fleetCategory === "own" ? "羚牛车辆" : "外部车辆", - ord.orderId, - ord.time, - SOURCE_TYPE_LABEL[ord.source] || ord.source, - ord.certNo, - vh.fleetCategory === "own" - ? DAILY_VERIFY_LABEL[ord.verifyStatus || "unverified"] || - "未核对" - : "-", - ord.unitPrice, - ord.kg, - ord.amount, - ]); - } - }); - }); - }); - }); - - const fileName = isProfitDrill - ? `${year}年_加氢利润_收入成本穿透明细.xlsx` - : isMonthDrill - ? `${year}年_本月加氢_量费占年比穿透明细.xlsx` - : isDayDrill - ? `${year}年_本日加氢_量费占月比穿透明细.xlsx` - : `${year}年_${label}_加氢站_客户_车牌_单笔订单穿透流水账单.xlsx`; - const sheetName = isProfitDrill - ? "加氢利润收入成本明细" - : isMonthDrill - ? "本月加氢穿透明细" - : isDayDrill - ? "本日加氢穿透明细" - : "KPI穿透订单明细"; - downloadExcelAoa(aoa, fileName, sheetName); - }; - - return ( -
-
e.stopPropagation()}> - {/* Modal 头部 */} -
-
- -
-
- - 「{year}」{label}明细 - -
-
-
- -
- -
-
- - {/* Modal 内容区 */} -
- {isFlatOverviewDrill ? ( - isStationCustomerDrill ? ( - <> -
-
- 加氢站 - - {stationCustTarget} - -
-
- - 内部客户加氢总量 - - - {stationCustInternalKg.toLocaleString("zh-CN")} Kg - -
-
- - 外部客户加氢总量 - - - {stationCustExternalKg.toLocaleString("zh-CN")} Kg - -
-
- 合计加氢总量 - - {stationCustTotalKg.toLocaleString("zh-CN")} Kg - -
-
-
-
- -
- 该站内部客户与外部客户加氢总量(可纵向滚动) -
-
-
-
- - - - - - - - - - - {stationCustomerRows.length === 0 ? ( - - - - ) : ( - stationCustomerRows.map((row) => ( - - - - - - - )) - )} - -
客户类型加氢总量 (Kg)站内占比
- 暂无该站客户数据 -
- {row.customerName} - - - {row.category === "internal" - ? "内部客户" - : "外部客户"} - - - {row.totalKg.toLocaleString("zh-CN")} - - {stationCustTotalKg > 0 - ? ( - (row.totalKg / stationCustTotalKg) * - 100 - ).toFixed(1) - : "0.0"} - % -
-
- - ) : isRegionDrill ? ( - <> -
-
- 区域 - - {regionLabel} - -
-
- 加氢总量 - - {(regionKgSum / 1000).toFixed(2)} T - -
-
- 覆盖加氢站数 - - {regionStationRows.length} 站 - -
-
-
-
- -
- 该{regionKind}各加氢站加氢总量与区域内占比(可纵向滚动) -
-
-
-
- - - - - - - - - - - - {regionStationRows.length === 0 ? ( - - - - ) : ( - regionStationRows.map((row, idx) => ( - - - - - - - - )) - )} - -
#加氢站所属省份加氢总量 (Kg)区域内占比
- 该区域暂无加氢站数据 -
{idx + 1} - {row.name} - {row.province} - {row.kg.toLocaleString("zh-CN")} - - {row.pct.toFixed(1)}% -
-
- - ) : ( - <> -
- {isMonthBarDrill ? ( - <> -
- - 内部客户加氢总量 - - - {monthBarInternalSum.toLocaleString("zh-CN")} Kg - -
-
- - 外部客户加氢总量 - - - {monthBarExternalSum.toLocaleString("zh-CN")} Kg - -
-
- - 合计加氢总量 - - - {monthBarTotalSum.toLocaleString("zh-CN")} Kg - -
- - ) : isMonthIncomeDrill ? ( - <> -
- - 客户收入合计 - - - ¥{monthIncomeSum.toLocaleString("zh-CN")} - -
-
- 站均收入 - - ¥ - {(stationRevRows.length - ? Math.round(monthIncomeSum / stationRevRows.length) - : 0 - ).toLocaleString("zh-CN")} - -
- - ) : ( - <> -
- - 成本支出合计 - - - ¥{monthCostSum.toLocaleString("zh-CN")} - -
-
- 站均成本 - - ¥ - {(stationRevRows.length - ? Math.round(monthCostSum / stationRevRows.length) - : 0 - ).toLocaleString("zh-CN")} - -
- - )} -
- 覆盖加氢站数 - - { - (isMonthBarDrill ? stationMonthRows : stationRevRows) - .length - }{" "} - 站 - -
-
- -
-
- { - setStationFilter(v); - setCustomerFilter("all"); - setPlateFilter("all"); - }} - options={stationOptions} - allLabel="全部加氢站" - placeholder="搜索加氢站…" - width={220} - /> -
- - - -
- -
- {isMonthBarDrill - ? "该月各加氢站:内部客户加氢总量 · 外部客户加氢总量 · 合计" - : isMonthIncomeDrill - ? "该月各加氢站客户收入" - : "该月各加氢站成本支出"} -
-
-
- -
- - - - - - {isMonthBarDrill ? ( - <> - - - - - ) : ( - - )} - - - - {isMonthBarDrill ? ( - stationMonthRows.length === 0 ? ( - - - - ) : ( - stationMonthRows.map((row) => ( - - - - - - - - )) - ) - ) : stationRevRows.length === 0 ? ( - - - - ) : ( - stationRevRows.map((row) => ( - - - - - - )) - )} - -
加氢站所属省份 - 内部客户加氢总量 (Kg) - - 外部客户加氢总量 (Kg) - - 合计加氢总量 (Kg) - - {isMonthIncomeDrill - ? "客户收入 (元)" - : "成本支出 (元)"} -
- 暂无符合筛选的加氢站数据 -
- {row.stationName} - - {row.province} - - {row.internalKg.toLocaleString("zh-CN")} - - {row.externalKg.toLocaleString("zh-CN")} - - {row.totalKg.toLocaleString("zh-CN")} -
- 暂无符合筛选的加氢站数据 -
- {row.stationName} - {row.province} - ¥ - {(isMonthIncomeDrill - ? row.incomeYuan - : row.costYuan - ).toLocaleString("zh-CN")} -
-
- - ) - ) : ( - <> - {/* 汇总与追溯证明 Card */} -
- {isProfitDrill ? ( - <> -
- 收入合计 - - ¥ - {toIncomeYuan( - filteredStations.reduce( - (s, st) => s + feeWanToYuan(st.totalFeeWan), - 0, - ), - ).toLocaleString("zh-CN")} - -
-
- 成本合计 - - ¥ - {filteredStations - .reduce( - (s, st) => s + feeWanToYuan(st.totalFeeWan), - 0, - ) - .toLocaleString("zh-CN")} - -
-
- 加氢利润 - - ¥{(HOST_KPI.profitWan * 10000).toLocaleString("zh-CN")} - -
-
- 覆盖加氢站数 - - {filteredStations.length} 站 - -
- - ) : isMonthDrill ? ( - <> -
- 本月加氢量 - - {( - toMonthKg( - filteredStations.reduce( - (s, st) => s + st.totalKg, - 0, - ), - ) / 1000 - ).toFixed(2)}{" "} - T - -
-
- 本月加氢费 - - ¥{(toMonthFee(filteredYearFeeYuan) / 10000).toFixed(2)}{" "} - 万元 - -
-
- 加氢费占年比 - - {(monthShare * 100).toFixed(2)}% - -
-
- 覆盖加氢站数 - - {filteredStations.length} 站 - -
- - ) : isDayDrill ? ( - <> -
- 本日加氢量 - - {toDayKg( - filteredStations.reduce((s, st) => s + st.totalKg, 0), - ).toLocaleString("zh-CN")}{" "} - Kg - -
-
- 本日加氢费 - - ¥{toDayFee(filteredYearFeeYuan).toLocaleString("zh-CN")} - -
-
- 加氢费占月比 - - {filteredMonthFeeYuan > 0 - ? ( - (toDayFee(filteredYearFeeYuan) / - filteredMonthFeeYuan) * - 100 - ).toFixed(2) - : "0.00"} - % - -
-
- 覆盖加氢站数 - - {filteredStations.length} 站 - -
- - ) : ( - <> -
- 数据归集总量 - - {( - filteredStations.reduce( - (s, st) => s + st.totalKg, - 0, - ) / 1000 - ).toFixed(2)}{" "} - T - -
-
- 数据总金额 - - ¥ - {filteredStations - .reduce((s, st) => s + parseFloat(st.totalFeeWan), 0) - .toFixed(2)}{" "} - 万元 - -
-
- 覆盖加氢站数 - - {filteredStations.length} 站 - -
-
- - 数据源可追溯率 - - - 100% (含API/站点上报凭证) - -
- - )} -
- - {/* 过滤筛选条:加氢站 / 客户 / 车辆(可搜索)+ 车辆归属 + 导出 */} -
-
- { - setStationFilter(v); - setCustomerFilter("all"); - setPlateFilter("all"); - }} - options={stationOptions} - allLabel="全部加氢站" - placeholder="搜索加氢站…" - width={200} - /> - { - setCustomerFilter(v); - setPlateFilter("all"); - }} - options={customerOptions} - allLabel="全部客户" - placeholder="搜索客户…" - width={200} - /> - -
- - - -
- -
- 提示:点击表格行可四级层层展开 【加氢站 → 客户 → 车牌 → - 单笔订单与核对明细】 -
-
-
- -
- ‹ 左右滑动查看完整数据与凭证列 › -
- - {/* 穿透树形表格 */} -
- - - - - - - - - {isProfitDrill ? ( - <> - - - - - ) : isMonthDrill ? ( - <> - - - - - ) : isDayDrill ? ( - <> - - - - - ) : ( - <> - - - - )} - - - - {filteredStations.map((st) => { - const isStExpanded = !!expandedStations[st.stationId]; - const stationVehicles = st.customers.reduce< - Parameters[0] - >( - (vehicles, customer) => - vehicles.concat( - customer.vehicles as unknown as Parameters< - typeof aggregateVehiclesVerifyStatus - >[0], - ), - [], - ); - const stationVerify = - aggregateVehiclesVerifyStatus(stationVehicles); - - return ( - - {/* Level 1: 加氢站 */} - toggleStation(st.stationId)} - > - - - - - - {renderMetricCells( - st.totalKg, - feeWanToYuan(st.totalFeeWan), - )} - - - {/* Level 2: 客户层 */} - {isStExpanded && - st.customers.map((cust) => { - const custKey = `${st.stationId}_${cust.customerId}`; - const isCustExpanded = - !!expandedCustomers[custKey]; - - return ( - - - toggleCustomer( - st.stationId, - cust.customerId, - ) - } - > - - - - - - {renderMetricCells( - cust.totalKg, - feeWanToYuan(cust.totalFeeWan), - )} - - - {/* Level 3: 车辆及数据来源与凭证层 (支持折叠展开单笔订单) */} - {isCustExpanded && - cust.vehicles.map((vh, idx) => { - const vhKey = `${st.stationId}_${cust.customerId}_${vh.plateNo}`; - const isVhExpanded = - !!expandedVehicles[vhKey]; - const showAllOrders = - !!expandedAllVehicleOrders[vhKey]; - const displayOrders = showAllOrders - ? vh.orders - : vh.orders.slice(0, 5); - const aggVerify = - computeVehicleVerifyStatus( - vh.orders, - vh.fleetCategory, - ); - // 无法识别车牌 →「无车牌」类目,与有牌车辆同级;标签固定「外部车辆」。能识别 →「羚牛车辆」/「外部车辆」按归属。 - const isNoPlate = - !vh.plateNo || - /无车牌/.test(vh.plateNo); - const plateDisplay = isNoPlate - ? "无车牌" - : vh.plateNo; - const isOwnFleet = - !isNoPlate && - vh.fleetCategory === "own"; - - return ( - - - toggleVehicle( - st.stationId, - cust.customerId, - vh.plateNo, - ) - } - > - - - - - - {renderMetricCells( - vh.kg, - vh.amount, - )} - - - {/* Level 4: 单笔加氢订单流水与核对明细层 */} - {isVhExpanded && ( - <> - {displayOrders.map((ord) => ( - - - - - - - {renderMetricCells( - ord.kg, - ord.amount, - )} - - ))} - - - - - - )} - - ); - })} - - ); - })} - - ); - })} - -
加氢站 / 客户 / 车辆与凭证链路类型 / 归属数据来源及凭证号核对状态加氢笔数收入 (元)成本 (元)利润 (元) - 本月加氢量 (Kg) - - 本月加氢费 (元) - 加氢费占年比 - 本日加氢量 (Kg) - - 本日加氢费 (元) - 加氢费占月比加氢总量 (Kg)加氢金额 (元)
- - {isStExpanded ? "▼" : "►"} - - {st.stationName} - - ({st.customers.length} 家客户) - - - - 全量自动归集 - - {renderAggVerifyTag( - stationVerify, - "本站羚牛车辆", - )} - - {st.customers.reduce( - (sum, c) => - sum + - c.vehicles.reduce((vs, v) => vs + v.count, 0), - 0, - )}{" "} - 笔 -
- - {isCustExpanded ? "▼" : "►"} - - └─ 客户:{cust.customerName} - - - {cust.vehicles.length} 辆车挂载 - - - {cust.vehicles.reduce( - (sum, v) => sum + v.count, - 0, - )}{" "} - 笔 -
- - {isVhExpanded ? "▼" : "►"} - - - {plateDisplay} - - - ({vh.count} 笔订单) - - - {renderFleetTag(isOwnFleet)} - - {renderSourceTag(vh.source)} - - {renderVehicleVerifyTag( - vh.fleetCategory, - aggVerify, - )} - - {vh.count} 笔 -
- - └── - - - 订单编号 - - - {ord.orderId} - - - ({ord.time}) - - - - 单价 ¥ - {ord.unitPrice.toFixed(2)} - /Kg - - - {renderSourceTag( - ord.source, - )} - - {renderOrderVerifyTag( - vh.fleetCategory, - ord.verifyStatus, - )} - - 1 笔 -
- - └── - - - - - {showAllOrders - ? `包含该车辆共 ${vh.count} 笔历史加氢订单,已展示全量 ${vh.orders.length} 笔穿透流水` - : `包含该车辆共 ${vh.count} 笔历史加氢订单,默认展示近 ${displayOrders.length} 笔穿透核对流水明细`} - - -
-
- - )} -
-
-
- ); -} - -interface CustomerBillDrillModalProps { - customerName: string; - year: number; - onClose: () => void; -} - -function CustomerBillDrillModal({ - customerName, - year, - onClose, -}: CustomerBillDrillModalProps) { - const [searchTerm, setSearchTerm] = useState(""); - const [expandedDates, setExpandedDates] = useState>({ - "2026-08-08": true, // 默认展开最新一日 - }); - - const custSummary = MOCK_CUSTOMER_SUMMARY_LIST.find( - (c) => c.name === customerName, - ); - const bearerLabel = custSummary?.bearer === "lingniu" ? "羚牛" : "客户"; - const summaryKgT = custSummary ? parseFloat(custSummary.kgT) : 0; - const summaryCostWan = custSummary ? parseFloat(custSummary.costWan) : 0; - const summaryReceivableText = custSummary?.receivable ?? "—"; - - const toggleDate = (dateStr: string) => { - setExpandedDates((prev) => ({ ...prev, [dateStr]: !prev[dateStr] })); - }; - - // 根据客户名称与年份建立【客户 -> 日期 -> 车牌加氢记录(最小粒度)】层层下钻明细 - const billData = useMemo(() => { - const dates = [ - { - date: "2026-08-08", - stationCount: 2, - records: [ - { - plateNo: "浙A88888F", - fleetCategory: "own" as const, - stationName: "嘉兴中石化滨海加氢站", - time: "2026-08-08 08:15", - kg: 45.2, - receivable: 1356.0, - }, - { - plateNo: "浙A66666F", - fleetCategory: "own" as const, - stationName: "嘉兴嘉锦加氢站", - time: "2026-08-08 09:30", - kg: 54.8, - receivable: 1644.0, - }, - { - plateNo: "粤B12345D", - fleetCategory: "external" as const, - stationName: "嘉兴中石化滨海加氢站", - time: "2026-08-08 10:10", - kg: 350.0, - receivable: 10500.0, - }, - { - plateNo: "粤B99881D", - fleetCategory: "external" as const, - stationName: "嘉兴嘉锦加氢站", - time: "2026-08-08 14:20", - kg: 280.0, - receivable: 8400.0, - }, - ], - }, - { - date: "2026-08-07", - stationCount: 1, - records: [ - { - plateNo: "浙A88888F", - fleetCategory: "own" as const, - stationName: "嘉兴中石化滨海加氢站", - time: "2026-08-07 11:20", - kg: 48.0, - receivable: 1440.0, - }, - { - plateNo: "浙A33333F", - fleetCategory: "own" as const, - stationName: "嘉兴中石化滨海加氢站", - time: "2026-08-07 16:45", - kg: 52.0, - receivable: 1560.0, - }, - { - plateNo: "沪A66128D", - fleetCategory: "external" as const, - stationName: "嘉兴中石化滨海加氢站", - time: "2026-08-07 17:30", - kg: 120.0, - receivable: 3600.0, - }, - ], - }, - { - date: "2026-08-06", - stationCount: 2, - records: [ - { - plateNo: "浙A88888F", - fleetCategory: "own" as const, - stationName: "嘉兴中石化滨海加氢站", - time: "2026-08-06 09:10", - kg: 42.5, - receivable: 1275.0, - }, - { - plateNo: "川A88901", - fleetCategory: "external" as const, - stationName: "成都中石化天府机场高速北站加氢站", - time: "2026-08-06 13:15", - kg: 210.0, - receivable: 6300.0, - }, - { - plateNo: "川A88902", - fleetCategory: "external" as const, - stationName: "成都中石化天府机场高速北站加氢站", - time: "2026-08-06 15:50", - kg: 180.0, - receivable: 5400.0, - }, - ], - }, - { - date: "2026-08-05", - stationCount: 1, - records: [ - { - plateNo: "浙A66666F", - fleetCategory: "own" as const, - stationName: "桐乡中石化绿能加氢站", - time: "2026-08-05 10:40", - kg: 50.0, - receivable: 1500.0, - }, - { - plateNo: "渝A66881", - fleetCategory: "external" as const, - stationName: "桐乡中石化绿能加氢站", - time: "2026-08-05 14:05", - kg: 310.0, - receivable: 9300.0, - }, - ], - }, - ]; - - return dates - .map((d) => { - const filteredRecords = d.records.filter((r) => { - if (!searchTerm) return true; - const term = searchTerm.toLowerCase(); - return ( - (r.plateNo && r.plateNo.toLowerCase().includes(term)) || - r.stationName.toLowerCase().includes(term) - ); - }); - - const dayKg = filteredRecords.reduce((sum, r) => sum + r.kg, 0); - const dayReceivable = filteredRecords.reduce( - (sum, r) => sum + r.receivable, - 0, - ); - const dayCost = - summaryKgT > 0 - ? Math.round( - (dayKg / (summaryKgT * 1000)) * summaryCostWan * 10000 * 100, - ) / 100 - : Math.round(dayReceivable * 0.9 * 100) / 100; - - return { - ...d, - records: filteredRecords, - totalKg: Math.round(dayKg * 10) / 10, - totalReceivable: Math.round(dayReceivable * 100) / 100, - totalCost: dayCost, - }; - }) - .filter((d) => d.records.length > 0); - }, [searchTerm, summaryKgT, summaryCostWan]); - - const totalKgSum = useMemo(() => { - return billData.reduce((sum, d) => sum + d.totalKg, 0); - }, [billData]); - - const totalReceivableSum = useMemo(() => { - return billData.reduce((sum, d) => sum + d.totalReceivable, 0); - }, [billData]); - - // 导出客户账单 Excel (.xlsx) - const handleExportExcel = () => { - const aoa: (string | number)[][] = [ - [ - "客户名称", - "日期", - "车牌号", - "车辆归属", - "加氢站", - "加氢时间", - "加氢量(Kg)", - "应收(元)", - "已收", - "未收", - ], - ]; - - billData.forEach((d) => { - d.records.forEach((r) => { - aoa.push([ - customerName, - d.date, - r.plateNo || "无车牌(散车)", - r.fleetCategory === "own" ? "羚牛车辆" : "外部车辆", - r.stationName, - r.time, - r.kg, - r.receivable, - "敬请期待 (对接账户)", - "敬请期待 (对接账单)", - ]); - }); - }); - - downloadExcelAoa( - aoa, - `客户账单穿透流水_${customerName}_${year}年.xlsx`, - "客户账单穿透流水", - ); - }; - - return ( -
-
e.stopPropagation()}> - {/* Modal 头部 */} -
-
- -
-
- 「{customerName}」客户账单明细 -
-
-
- -
- -
-
- - {/* Modal 内容区 */} -
- {/* 列表关键字段汇总 */} -
-
- 承担方 - - {bearerLabel} - -
-
- 加氢量 - - {summaryKgT > 0 - ? summaryKgT.toFixed(2) - : (totalKgSum / 1000).toFixed(2)}{" "} - T - -
-
- 成本支出 - - ¥{summaryCostWan > 0 ? summaryCostWan.toFixed(2) : "—"}{" "} - 万元 - -
-
- 应收 - - {summaryReceivableText} - -
-
- 已收 - - 敬请期待 - -
-
- 未收 - - 敬请期待 - -
-
- - {/* 搜寻卡 */} -
-
-
- - setSearchTerm(e.target.value)} - /> - {searchTerm && ( - - )} -
- -
- 按日展开:加氢量 · 成本支出 · 应收 · 已收 · 未收 -
-
-
- -
‹ 左右滑动查看完整关键字段 ›
- - {/* 表格:对齐客户账单汇总关键字段 */} -
- - - - - - - - - - - - - - - {billData.map((day) => { - const isExpanded = !!expandedDates[day.date]; - - return ( - - {/* Level 2: 日期层 */} - toggleDate(day.date)} - > - - - - - - - - - - - {/* Level 3: 车牌加氢记录 (最小粒度) */} - {isExpanded && - day.records.map((rec, rIdx) => { - const rowCost = - day.totalKg > 0 - ? Math.round( - (rec.kg / day.totalKg) * day.totalCost * 100, - ) / 100 - : 0; - return ( - - - - - - - - - - - ); - })} - - ); - })} - -
日期 / 车牌明细加氢站承担方加氢量(Kg)成本支出(元)应收(元)已收未收
-
- - {isExpanded ? "▼" : "►"} - - 📅 {day.date} -
-
- 涉及 {day.stationCount} 个加氢站 ·{" "} - {day.records.length} 笔 - - - {bearerLabel} - - - {day.totalKg.toLocaleString("zh-CN")} Kg - - ¥ - {day.totalCost.toLocaleString("zh-CN", { - minimumFractionDigits: 2, - })} - - ¥ - {day.totalReceivable.toLocaleString("zh-CN", { - minimumFractionDigits: 2, - })} - - - 敬请期待 - - - - 敬请期待 - -
-
- - └── - - - {rec.plateNo || "无车牌(散车)"} - - - {rec.time} - -
-
- {rec.stationName} - - - {bearerLabel} - - - {rec.kg.toFixed(1)} Kg - - ¥ - {rowCost.toLocaleString("zh-CN", { - minimumFractionDigits: 2, - })} - - ¥ - {rec.receivable.toLocaleString("zh-CN", { - minimumFractionDigits: 2, - })} - - - 敬请期待 - - - - 敬请期待 - -
-
-
-
-
- ); -} - -interface StationBillDrillModalProps { - stationName: string; - province: string; - year: number; - onClose: () => void; -} - -function StationBillDrillModal({ - stationName, - province, - year, - onClose, -}: StationBillDrillModalProps) { - const [searchTerm, setSearchTerm] = useState(""); - const [expandedDates, setExpandedDates] = useState>({ - "2026-08-08": true, // 默认展开最新一日 - }); - - const stSummary = - MOCK_STATION_SUMMARY_LIST.find((s) => s.name === stationName) || - MOCK_STATION_SUMMARY_LIST.find( - (s) => - stationName.includes(s.name.slice(0, 8)) || - s.name.includes(stationName.slice(0, 8)), - ); - const summaryKgT = stSummary ? parseFloat(stSummary.kgT) : 0; - const summaryKgPct = stSummary?.kgPct ?? 0; - const summaryIncomeWan = stSummary ? parseFloat(stSummary.incomeWan) : 0; - const summaryIncomePct = stSummary?.incomePct ?? 0; - - const toggleDate = (dateStr: string) => { - setExpandedDates((prev) => ({ ...prev, [dateStr]: !prev[dateStr] })); - }; - - // 全站累计基准总量 (Kg) 与 总氢费收入 (元) — 优先取汇总表关键字段 - const totalStationKg = - summaryKgT > 0 ? Math.round(summaryKgT * 1000) : 243660; - const totalStationIncome = - summaryIncomeWan > 0 ? Math.round(summaryIncomeWan * 10000) : 526600; - - // 根据加氢站构建【加氢站 -> 所有日期的加氢量、占比、氢费收入、收入占比】下钻明细 - const stationData = useMemo(() => { - const dates = [ - { - date: "2026-08-08", - records: [ - { - plateNo: "浙A88888F", - fleetCategory: "own" as const, - customerName: "羚牛氢能科技(广东)有限公司", - time: "2026-08-08 08:15", - kg: 450.2, - income: 13506.0, - }, - { - plateNo: "浙A66666F", - fleetCategory: "own" as const, - customerName: "嘉兴市乍浦港口经营有限公司", - time: "2026-08-08 09:30", - kg: 540.8, - income: 16224.0, - }, - { - plateNo: "粤B12345D", - fleetCategory: "external" as const, - customerName: "广东氢动力科技服务有限公司", - time: "2026-08-08 10:10", - kg: 350.0, - income: 10500.0, - }, - { - plateNo: "沪A66128D", - fleetCategory: "external" as const, - customerName: "上海明纳物流有限公司", - time: "2026-08-08 14:20", - kg: 280.0, - income: 8400.0, - }, - ], - }, - { - date: "2026-08-07", - records: [ - { - plateNo: "浙A88888F", - fleetCategory: "own" as const, - customerName: "羚牛氢能科技(广东)有限公司", - time: "2026-08-07 11:20", - kg: 480.0, - income: 14400.0, - }, - { - plateNo: "浙A33333F", - fleetCategory: "own" as const, - customerName: "嘉兴益顺冷链物流有限公司", - time: "2026-08-07 16:45", - kg: 520.0, - income: 15600.0, - }, - { - plateNo: "粤B99881D", - fleetCategory: "external" as const, - customerName: "嘉兴智奇供应链管理有限公司", - time: "2026-08-07 17:30", - kg: 410.0, - income: 12300.0, - }, - ], - }, - { - date: "2026-08-06", - records: [ - { - plateNo: "浙A88888F", - fleetCategory: "own" as const, - customerName: "羚牛氢能科技(广东)有限公司", - time: "2026-08-06 09:10", - kg: 425.0, - income: 12750.0, - }, - { - plateNo: "川A88901", - fleetCategory: "external" as const, - customerName: "四川群彬物流有限公司", - time: "2026-08-06 13:15", - kg: 610.0, - income: 18300.0, - }, - { - plateNo: "川A88902", - fleetCategory: "external" as const, - customerName: "四川拱照物流有限公司", - time: "2026-08-06 15:50", - kg: 580.0, - income: 17400.0, - }, - ], - }, - { - date: "2026-08-05", - records: [ - { - plateNo: "浙A66666F", - fleetCategory: "own" as const, - customerName: "嘉兴市乍浦港口经营有限公司", - time: "2026-08-05 10:40", - kg: 500.0, - income: 15000.0, - }, - { - plateNo: "渝A66881", - fleetCategory: "external" as const, - customerName: "重庆金时源供应链有限公司", - time: "2026-08-05 14:05", - kg: 710.0, - income: 21300.0, - }, - ], - }, - ]; - - return dates - .map((d) => { - const filteredRecords = d.records.filter((r) => { - if (!searchTerm) return true; - const term = searchTerm.toLowerCase(); - return ( - d.date.includes(term) || - (r.plateNo && r.plateNo.toLowerCase().includes(term)) || - r.customerName.toLowerCase().includes(term) - ); - }); - - const dayKg = filteredRecords.reduce((sum, r) => sum + r.kg, 0); - const dayIncome = filteredRecords.reduce((sum, r) => sum + r.income, 0); - - const dayKgPct = Math.round((dayKg / totalStationKg) * 10000) / 100; - const dayIncomePct = - Math.round((dayIncome / totalStationIncome) * 10000) / 100; - - return { - ...d, - records: filteredRecords, - totalKg: Math.round(dayKg * 10) / 10, - totalKgPct: dayKgPct, - totalIncome: Math.round(dayIncome * 100) / 100, - totalIncomePct: dayIncomePct, - }; - }) - .filter((d) => d.records.length > 0); - }, [searchTerm]); - - const totalKgSum = useMemo(() => { - return stationData.reduce((sum, d) => sum + d.totalKg, 0); - }, [stationData]); - - const totalIncomeSum = useMemo(() => { - return stationData.reduce((sum, d) => sum + d.totalIncome, 0); - }, [stationData]); - - // 导出加氢站按日账单 Excel (.xlsx) - const handleExportExcel = () => { - const aoa: (string | number)[][] = [ - [ - "加氢站名称", - "所属省份", - "日期", - "车牌号", - "车辆归属", - "关联客户", - "加氢时间", - "加氢量(Kg)", - "加氢量占比", - "氢费收入(元)", - "收入占比", - ], - ]; - - stationData.forEach((d) => { - d.records.forEach((r) => { - const rKgPct = ((r.kg / totalStationKg) * 100).toFixed(2) + "%"; - const rIncomePct = - ((r.income / totalStationIncome) * 100).toFixed(2) + "%"; - aoa.push([ - stationName, - province, - d.date, - r.plateNo || "无车牌(散车)", - r.fleetCategory === "own" ? "羚牛车辆" : "外部车辆", - r.customerName, - r.time, - r.kg, - rKgPct, - r.income, - rIncomePct, - ]); - }); - }); - - downloadExcelAoa( - aoa, - `加氢站按日账单穿透_${stationName}_${year}年.xlsx`, - "站按日账单穿透", - ); - }; - - return ( -
-
e.stopPropagation()}> - {/* Modal 头部 */} -
-
- -
-
- 「{stationName}」加氢汇总明细 -
-
-
- -
- -
-
- - {/* Modal 内容区 */} -
- {/* 列表关键字段:加氢量 / 占比 / 氢费收入 / 收入占比 */} -
-
- 所属省份 - - {province} - -
-
- 加氢量 - - {(totalStationKg / 1000).toFixed(2)}{" "} - T - -
-
- 占比 - - {summaryKgPct.toFixed(1)}% - -
-
- 氢费收入 - - ¥{(totalStationIncome / 10000).toFixed(2)}{" "} - 万元 - -
-
- 收入占比 - - {summaryIncomePct.toFixed(1)}% - -
-
- - {/* 搜寻卡 */} -
-
-
- - setSearchTerm(e.target.value)} - /> - {searchTerm && ( - - )} -
- -
- 按日展开关键字段:加氢量 · 占比 · 氢费收入 · 收入占比 -
-
-
- -
- ‹ 左右滑动查看完整数据与状态 › -
- - {/* 表格:对齐加氢站汇总关键字段(按日) */} -
- - - - - - - - - - - - - - {stationData.map((day) => { - const isExpanded = !!expandedDates[day.date]; - - return ( - - {/* Level 1: 所有日期的加氢量、占比、氢费收入、收入占比 */} - toggleDate(day.date)} - > - - - - - - - - - - {/* Level 2: 车牌/客户加氢记录 (最小粒度) */} - {isExpanded && - day.records.map((rec, rIdx) => { - const rKgPct = - Math.round((rec.kg / totalStationKg) * 10000) / 100; - const rIncomePct = - Math.round( - (rec.income / totalStationIncome) * 10000, - ) / 100; - - return ( - - - - - - - - - - ); - })} - - ); - })} - -
日期 / 车牌明细关联客户 / 车辆归属加氢时间加氢量(Kg)加氢量占比氢费收入(元)收入占比
-
- - {isExpanded ? "▼" : "►"} - - 📅 {day.date} -
-
- {day.records.length} 笔车辆加氢发生 - - 当天汇总 - - {day.totalKg.toLocaleString("zh-CN")} Kg - -
-
-
-
- - {day.totalKgPct.toFixed(2)}% - -
-
- ¥ - {day.totalIncome.toLocaleString("zh-CN", { - minimumFractionDigits: 2, - })} - -
-
-
-
- - {day.totalIncomePct.toFixed(2)}% - -
-
-
- - └── - - - {rec.plateNo || "无车牌(散车)"} - - - {rec.fleetCategory === "own" - ? "羚牛" - : "外部"} - -
-
- {rec.customerName} - - {rec.time} - - {rec.kg.toFixed(1)} Kg - - - {rKgPct.toFixed(2)}% - - - ¥ - {rec.income.toLocaleString("zh-CN", { - minimumFractionDigits: 2, - })} - - - {rIncomePct.toFixed(2)}% - -
-
-
-
-
- ); -} - -function StationMonthTable({ - rows, - activeId, - onOpen, -}: { - rows: ReturnType; - activeId: string | null; - onOpen: (id: string, label: string) => void; -}) { - if (!rows.length) - return
本筛选下暂无站月发生额
; - return ( -
- - - - - - - - - - - - - {rows.map((r, i) => ( - onOpen(r.stationId, r.stationName)} - > - - - - - - - - ))} - -
#加氢站月份发生额加氢量未核金额
{i + 1}{r.stationName}{r.month}{formatYuan(r.amount)}{formatKg(r.quantityKg)}{formatYuan(r.unverifiedAmount)}
-
- ); -} - -function CustomerAttrTable({ - rows, - activeId, - onOpen, -}: { - rows: ReturnType; - activeId: string | null; - onOpen: (id: string, label: string) => void; -}) { - if (!rows.length) - return
本筛选下暂无客户归属
; - return ( -
- - - - - - - - - - - - - {rows.map((r, i) => ( - onOpen(r.customerId, r.customerName)} - > - - - - - - - - ))} - -
#客户承担方加氢量我司成本未核
{i + 1}{r.customerName}{r.borneLabel}{formatKg(r.quantityKg)}{formatYuan(r.companyCost)}{formatYuan(r.unverifiedAmount)}
-
- ); -} - -function OrderTable({ rows }: { rows: H2OrderRow[] }) { - if (!rows.length) { - return
本筛选下暂无我司成本明细
; - } - return ( -
- - - - - - - - - - - - - - - - {rows.map((r) => ( - - - - - - - - - - - - ))} - -
时间加氢站车牌客户加氢量金额成本维度核对来源
{r.occurredAt}{r.stationName}{r.plateNo}{r.customerName}{formatKg(r.quantityKg)}{formatYuan(r.amount)}{costDimLabel(r)} - - {r.verifyStatus === "verified" ? "已核对" : "未核对"} - - {SOURCE_LABEL[r.source]}
-
- ); -} - -interface BiCustomDatePickerProps { - label: string; - value: string; // YYYY-MM-DD | YYYY-MM | YYYY - onChange: (dateStr: string) => void; -} - -function BiCustomDatePicker({ - label, - value, - onChange, -}: BiCustomDatePickerProps) { - const [isOpen, setIsOpen] = useState(false); - const containerRef = useRef(null); - - // 面板视图模式:'day' | 'month' | 'year' - const [pickerMode, setPickerMode] = useState<"day" | "month" | "year">("day"); - - const parsedDate = useMemo(() => { - const parts = value.split("-"); - const year = parseInt(parts[0], 10) || 2026; - const month = parseInt(parts[1], 10) || 8; - const day = parseInt(parts[2], 10) || 1; - return { year, month, day }; - }, [value]); - - const [viewYear, setViewYear] = useState(parsedDate.year); - const [viewMonth, setViewMonth] = useState(parsedDate.month); - - useEffect(() => { - if (isOpen) { - setViewYear(parsedDate.year); - setViewMonth(parsedDate.month); - const parts = value.split("-"); - if (parts.length === 1 && value.length === 4) { - setPickerMode("year"); - } else if (parts.length === 2) { - setPickerMode("month"); - } else { - setPickerMode("day"); - } - } - }, [isOpen, value, parsedDate]); - - useEffect(() => { - function handleClickOutside(e: MouseEvent) { - if ( - containerRef.current && - !containerRef.current.contains(e.target as Node) - ) { - setIsOpen(false); - } - } - if (isOpen) { - document.addEventListener("mousedown", handleClickOutside); - } - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, [isOpen]); - - const yearsList = [2026, 2025, 2024, 2023, 2022, 2021, 2020]; - const monthsList = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; - - const handleSelectYear = (y: number, e: React.MouseEvent) => { - e.stopPropagation(); - setViewYear(y); - if (pickerMode === "year") { - onChange(`${y}`); - setIsOpen(false); - } else { - setPickerMode(pickerMode === "day" ? "month" : "day"); - } - }; - - const handleSelectMonth = (m: number, e: React.MouseEvent) => { - e.stopPropagation(); - setViewMonth(m); - const mm = m < 10 ? `0${m}` : `${m}`; - if (pickerMode === "month") { - onChange(`${viewYear}-${mm}`); - setIsOpen(false); - } else { - setPickerMode("day"); - } - }; - - const handleSelectDay = (d: number, e: React.MouseEvent) => { - e.stopPropagation(); - const mm = viewMonth < 10 ? `0${viewMonth}` : `${viewMonth}`; - const dd = d < 10 ? `0${d}` : `${d}`; - onChange(`${viewYear}-${mm}-${dd}`); - setIsOpen(false); - }; - - const handlePrev = (e: React.MouseEvent) => { - e.stopPropagation(); - if (pickerMode === "day") { - if (viewMonth === 1) { - setViewYear((prev) => prev - 1); - setViewMonth(12); - } else { - setViewMonth((prev) => prev - 1); - } - } else { - setViewYear((prev) => prev - 1); - } - }; - - const handleNext = (e: React.MouseEvent) => { - e.stopPropagation(); - if (pickerMode === "day") { - if (viewMonth === 12) { - setViewYear((prev) => prev + 1); - setViewMonth(1); - } else { - setViewMonth((prev) => prev + 1); - } - } else { - setViewYear((prev) => prev + 1); - } - }; - - const daysInMonth = new Date(viewYear, viewMonth, 0).getDate(); - const firstDayWeek = new Date(viewYear, viewMonth - 1, 1).getDay(); - const daysArray = Array.from({ length: daysInMonth }, (_, i) => i + 1); - const emptyPrefixSlots = Array.from({ length: firstDayWeek }, (_, i) => i); - - return ( -
-
setIsOpen(!isOpen)} - > - {label} - {value} - -
- - {isOpen && ( -
- {/* 1. 粒度模式选择器: 按日 | 按月 | 按年 */} -
- - - -
- - {/* 2. 标头快速切年月 */} -
- -
- - {pickerMode === "day" && ( - - )} -
- -
- - {/* 3. 日视图 */} - {pickerMode === "day" && ( - <> -
- - - - - - - -
- -
- {emptyPrefixSlots.map((s) => ( - - ))} - {daysArray.map((d) => { - const mm = viewMonth < 10 ? `0${viewMonth}` : `${viewMonth}`; - const dd = d < 10 ? `0${d}` : `${d}`; - const isSelected = value === `${viewYear}-${mm}-${dd}`; - - return ( - - ); - })} -
- - )} - - {/* 4. 月视图 */} - {pickerMode === "month" && ( -
- {monthsList.map((m) => { - const mm = m < 10 ? `0${m}` : `${m}`; - const isSelected = - value === `${viewYear}-${mm}` || - (value.split("-").length === 3 && - parsedDate.year === viewYear && - parsedDate.month === m); - - return ( - - ); - })} -
- )} - - {/* 5. 年视图 */} - {pickerMode === "year" && ( -
- {yearsList.map((y) => { - const isSelected = value === `${y}` || parsedDate.year === y; - - return ( - - ); - })} -
- )} -
- )} -
- ); -} - -interface HostDailyViewProps { - updatedAt?: string; - onRefresh?: () => void; - startDate: string; - endDate: string; - onStartDateChange: (val: string) => void; - onEndDateChange: (val: string) => void; -} - -function HostDailyView({ - updatedAt, - onRefresh, - startDate, - endDate, - onStartDateChange, - onEndDateChange, -}: HostDailyViewProps) { - const [rangePreset, setRangePreset] = useState< - "week" | "month" | "15days" | "custom" - >("15days"); - const [fleetType, setFleetType] = useState("all"); - - // 上方时间预设连动 KPI 卡片标题 - const kpiRangeTitle = useMemo(() => { - if (rangePreset === "week") return "本周加氢量"; - if (rangePreset === "month") return "本月加氢量"; - if (rangePreset === "15days") return "近 15 天加氢量"; - return "自定义区间加氢量"; - }, [rangePreset]); - - const handlePresetChange = ( - preset: "week" | "month" | "15days" | "custom", - ) => { - setRangePreset(preset); - if (preset === "week") { - onStartDateChange("2026-08-03"); - onEndDateChange("2026-08-08"); - } else if (preset === "month") { - onStartDateChange("2026-08-01"); - onEndDateChange("2026-08-08"); - } else if (preset === "15days") { - onStartDateChange("2026-07-25"); - onEndDateChange("2026-08-08"); - } - }; - - // 日期归一化转换(兼容手选 年 YYYY、月 YYYY-MM、日 YYYY-MM-DD) - const normalizeDateStr = (dateStr: string, isEnd: boolean) => { - if (!dateStr) return isEnd ? "9999-12-31" : "0000-01-01"; - const parts = dateStr.split("-"); - if (parts.length === 1) { - return isEnd ? `${parts[0]}-12-31` : `${parts[0]}-01-01`; - } - if (parts.length === 2) { - const y = parseInt(parts[0], 10); - const m = parseInt(parts[1], 10); - if (isEnd) { - const lastDay = new Date(y, m, 0).getDate(); - const dd = lastDay < 10 ? `0${lastDay}` : `${lastDay}`; - return `${parts[0]}-${parts[1]}-${dd}`; - } - return `${parts[0]}-${parts[1]}-01`; - } - return dateStr; - }; - - const normStart = useMemo( - () => normalizeDateStr(startDate, false), - [startDate], - ); - const normEnd = useMemo(() => normalizeDateStr(endDate, true), [endDate]); - - // 1. 根据 startDate & endDate 动态生成或提取指定日期范围内的全量每日加氢数据列表 - const dateFilteredList = useMemo(() => { - return getDailyDataForRange(normStart, normEnd); - }, [normStart, normEnd]); - - // 2. 根据 fleetType 过滤出对应车辆归属下的加氢列表 ('all' 时包含内部与外部合并显示) - const filteredDailyList = useMemo(() => { - return filterDailyDataByFleet(dateFilteredList, fleetType); - }, [dateFilteredList, fleetType]); - - // 2. 动态计算关联的 KPI 及柱图统计数据 - const dailyKpis = useMemo(() => { - return calculateDailyKpis(filteredDailyList, fleetType); - }, [filteredDailyList, fleetType]); - - // 深层折叠/展开状态 - const [expandedDate, setExpandedDate] = useState("2026-08-08"); // 默认展开最新一天 - const [expandedStations, setExpandedStations] = useState< - Record - >({ - "2026-08-08_st-jx": true, // 默认展开嘉兴站,演示效果 - }); - const [expandedCustomers, setExpandedCustomers] = useState< - Record - >({ - "2026-08-08_st-jx_c-ln": true, // 默认展开羚牛客户,直观展示车辆与数据来源 - }); - - // 点击柱状图后的高亮锚点状态 - const [highlightedDate, setHighlightedDate] = useState(null); - - // 保证当前选中的展开日期始终在当前过滤数据集中 - useEffect(() => { - if ( - filteredDailyList.length > 0 && - (!expandedDate || !filteredDailyList.some((d) => d.date === expandedDate)) - ) { - setExpandedDate(filteredDailyList[0].date); - } - }, [filteredDailyList, expandedDate]); - - const maxQty = useMemo(() => { - if (!filteredDailyList.length) return 4000; - return Math.max(...filteredDailyList.map((d) => d.quantityKg), 3000); - }, [filteredDailyList]); - - const totalSum = useMemo(() => { - const sum = filteredDailyList.reduce( - (acc, item) => acc + item.quantityKg, - 0, - ); - return sum.toLocaleString("zh-CN", { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }); - }, [filteredDailyList]); - - /** 点击柱状图上的柱子:展开该日、锚点平滑滚动并高亮 */ - const handleBarClick = (date: string) => { - setExpandedDate(date); - setHighlightedDate(date); - - setTimeout(() => { - const el = document.getElementById(`daily-row-${date}`); - if (el) { - el.scrollIntoView({ behavior: "smooth", block: "center" }); - } - }, 60); - - setTimeout(() => { - setHighlightedDate((prev) => (prev === date ? null : prev)); - }, 2000); - }; - - const toggleStation = ( - dateKey: string, - stationId: string, - e: React.MouseEvent, - ) => { - e.stopPropagation(); - const key = `${dateKey}_${stationId}`; - setExpandedStations((prev) => ({ ...prev, [key]: !prev[key] })); - }; - - const toggleCustomer = ( - dateKey: string, - stationId: string, - customerId: string, - e: React.MouseEvent, - ) => { - e.stopPropagation(); - const key = `${dateKey}_${stationId}_${customerId}`; - setExpandedCustomers((prev) => ({ ...prev, [key]: !prev[key] })); - }; - - /** 导出按日加氢数据明细为 Excel (.xlsx) */ - const handleExportExcel = () => { - const aoa: (string | number)[][] = [ - [ - "日期", - "加氢站名称", - "加氢站类型", - "客户名称", - "客户属性", - "加氢时间", - "车牌号", - "车辆归属", - "数据来源", - "核对状态", - "单价(元/Kg)", - "加氢量(Kg)", - "加氢金额(元)", - "预充值余额", - ], - ]; - - filteredDailyList.forEach((d) => { - d.stations.forEach((st) => { - const stationPrecharge = - st.prechargeBalance ?? - (st.stationId.includes("jx") - ? 128500 - : st.stationId.includes("tx") - ? 86200 - : 45000); - st.customers?.forEach((cust) => { - const isInternalCust = - cust.customerCategory === "internal" || cust.customerId === "c-ln"; - cust.vehicles?.forEach((vh) => { - aoa.push([ - d.date, - st.stationName, - STATION_TYPE_LABEL[st.stationType] || st.stationType, - cust.customerName, - isInternalCust ? "内部客户" : "外部客户", - vh.time, - vh.plateNo || "无车牌(散车)", - vh.fleetCategory === "own" ? "羚牛车辆" : "外部车辆", - SOURCE_TYPE_LABEL[vh.source] || vh.source, - vh.fleetCategory === "own" - ? DAILY_VERIFY_LABEL[vh.verifyStatus || "unverified"] || - "未核对" - : "-", - vh.unitPrice, - vh.quantityKg, - vh.amountYuan, - `¥${stationPrecharge.toLocaleString("zh-CN", { minimumFractionDigits: 2 })} (对接站点管理)`, - ]); - }); - }); - }); - }); - - const fileDateStr = `${startDate.replace(/[/]/g, "")}-${endDate.replace(/[/]/g, "")}`; - const fleetName = - fleetType === "all" - ? "全部车辆" - : fleetType === "own" - ? "羚牛车辆" - : "外部车辆"; - downloadExcelAoa( - aoa, - `每日加氢数据明细_${fleetName}_${fileDateStr}.xlsx`, - "每日加氢明细", - ); - }; - - return ( -
- {/* 1. 顶栏时间/范围筛选器 */} -
-
-
-
- - - - -
- - { - onStartDateChange(val); - setRangePreset("custom"); - }} - /> - { - onEndDateChange(val); - setRangePreset("custom"); - }} - /> -
- -
-
- - - -
- - {updatedAt && ( - - {updatedAt} - - )} - - -
-
-
- - {/* 2. 4卡 Bento KPI */} -
-
-
- {kpiRangeTitle} - - - -
-
- - {dailyKpis.totalQuantityKg.toLocaleString("zh-CN")} - - Kg -
-
{dailyKpis.dateRange}
-
- -
-
- 车辆结构 - - - -
-
- - {dailyKpis.fleetTypeLabel} - -
-
{dailyKpis.fleetSubLabel}
-
- -
-
- 有效天数 - - - -
-
- {dailyKpis.activeDays} -
-
日均 {dailyKpis.dailyAvgKg}
-
- -
-
- 涉及加氢站 - - - -
-
- {dailyKpis.stationCount} - -
-
按明细站点去重
-
-
- - {/* 3. 每日加氢量堆积柱状图(分别显示内部客户与外部客户加氢量,点击柱子下锚定位) */} -
-
-
- 每日加氢量 - - (点击柱体下锚定位到对应日期明细) - - (点击柱体定位) -
-
-
- - - 内部客户 - - - - 外部客户 - -
- 时间单位:日 · 单位 Kg -
-
- -
-
- 峰值日 - {dailyKpis.peakDayLabel} -
-
- 低谷日 - {dailyKpis.troughDayLabel} -
-
- 零数日 - {dailyKpis.zeroDaysCount} 天 -
-
- -
‹ 左右滑动查看每日加氢趋势 ›
- -
-
0 ? Math.min(92, Math.round((dailyKpis.dailyAvgKgNum / maxQty) * 100)) : 50}%`, - }} - > - - 均值 {dailyKpis.dailyAvgKg} - -
- - {[...filteredDailyList].reverse().map((item) => { - // 计算当天内部客户加氢量与外部客户加氢量 - let dayOwnKg = 0; - let dayExtKg = 0; - item.stations.forEach((st) => { - st.customers.forEach((cust) => { - cust.vehicles.forEach((vh) => { - if (vh.fleetCategory === "own") { - dayOwnKg += vh.quantityKg; - } else { - dayExtKg += vh.quantityKg; - } - }); - }); - }); - dayOwnKg = Math.round(dayOwnKg * 10) / 10; - dayExtKg = Math.round(dayExtKg * 10) / 10; - - const totalKg = item.quantityKg > 0 ? item.quantityKg : 1; - const pct = Math.min( - 100, - Math.round((item.quantityKg / maxQty) * 100), - ); - const ownRatio = Math.round((dayOwnKg / totalKg) * 100); - const extRatio = Math.max(0, 100 - ownRatio); - - const isBarActive = expandedDate === item.date; - - const tooltipText = `${item.date} 加氢总量 ${Math.round(item.quantityKg).toLocaleString("zh-CN")} Kg\n├─ 内部客户: ${Math.round(dayOwnKg).toLocaleString("zh-CN")} Kg (${ownRatio}%)\n└─ 外部客户: ${Math.round(dayExtKg).toLocaleString("zh-CN")} Kg (${extRatio}%)\n(点击下锚定位到该日明细)`; - - return ( -
handleBarClick(item.date)} - > -
- {Math.round(item.quantityKg)} -
- - {/* 堆积柱体:上部外部客户,下部内部客户 */} -
- {dayExtKg > 0 && ( -
- )} - {dayOwnKg > 0 && ( -
- )} -
- -
- {item.shortDate} -
-
- ); - })} -
-
- - {/* 4. 每日数据明细多层钻取表格 */} -
-
-
- 每日加氢数据明细 - - (可多层下钻:按日 → 加氢站 → 客户 → 车辆及数据源) - - (可逐级下钻) -
- -
- -
- - - - - - - - - - - - {/* 合计行 */} - - - - - - - - - {filteredDailyList.map((row) => { - const isDateExpanded = expandedDate === row.date; - const isHighlighted = highlightedDate === row.date; - - return ( - - {/* Level 1: 日期行 */} - - setExpandedDate(isDateExpanded ? null : row.date) - } - > - - - - - - - - {/* Level 2: 加氢站层 */} - {isDateExpanded && - row.stations.map((st) => { - const stKey = `${row.date}_${st.stationId}`; - const isStExpanded = !!expandedStations[stKey]; - const stPrecharge = - st.prechargeBalance ?? - (st.stationId.includes("jx") - ? 128500 - : st.stationId.includes("tx") - ? 86200 - : 45000); - - return ( - - - toggleStation(row.date, st.stationId, e) - } - > - - - - - - - - {/* Level 3: 客户层 */} - {isStExpanded && - st.customers?.map((cust) => { - const custKey = `${row.date}_${st.stationId}_${cust.customerId}`; - const isCustExpanded = - !!expandedCustomers[custKey]; - const isInternalCust = - cust.customerCategory === "internal" || - cust.customerId === "c-ln"; - - return ( - - - toggleCustomer( - row.date, - st.stationId, - cust.customerId, - e, - ) - } - > - - - - - - - - {/* Level 4: 车辆及数据来源明细层 */} - {isCustExpanded && - cust.vehicles?.map((vh) => ( - - - - - - - - ))} - - ); - })} - - ); - })} - - ); - })} - -
日期 / 加氢站 / 客户 / 车辆明细单价 (元/Kg)加氢量 (Kg)金额 (元) / 环比预充值余额
- 合计 - - {totalSum} - - 对接站点管理 -
- - {row.stations.length > 0 - ? isDateExpanded - ? "▼" - : "►" - : "•"} - - {row.date} - - ({row.stations.length} 个加氢站) - - - {row.unitPrice.toFixed(2)} - - {row.quantityKg.toLocaleString("zh-CN", { - minimumFractionDigits: 2, - })} - - {row.momPct !== null ? ( - - {row.momPct > 0 - ? `+${row.momPct.toFixed(1)}%` - : `${row.momPct.toFixed(1)}%`} - - ) : ( - "-" - )} - -
- - {st.customers?.length - ? isStExpanded - ? "▼" - : "►" - : "•"} - - └ {st.stationName} - - {st.unitPrice.toFixed(2)} - - {st.quantityKg.toLocaleString("zh-CN", { - minimumFractionDigits: 2, - })} - - ¥ - {st.amountYuan.toLocaleString("zh-CN", { - minimumFractionDigits: 2, - })} - - ¥ - {stPrecharge.toLocaleString("zh-CN", { - minimumFractionDigits: 2, - })} -
-
- - - {cust.vehicles?.length - ? isCustExpanded - ? "▼" - : "►" - : "•"} - - - └─ 客户:{cust.customerName} - - - {isInternalCust ? ( - - - 内部客户 - - - {cust.quantityKg.toLocaleString( - "zh-CN", - { minimumFractionDigits: 2 }, - )}{" "} - Kg - - - ¥ - {cust.amountYuan.toLocaleString( - "zh-CN", - { minimumFractionDigits: 2 }, - )} - - - ) : ( - - - 外部客户 - - - {cust.quantityKg.toLocaleString( - "zh-CN", - { minimumFractionDigits: 2 }, - )}{" "} - Kg - - - ¥ - {cust.amountYuan.toLocaleString( - "zh-CN", - { minimumFractionDigits: 2 }, - )} - - - )} -
-
- - {cust.quantityKg.toLocaleString( - "zh-CN", - { minimumFractionDigits: 2 }, - )} - - ¥ - {cust.amountYuan.toLocaleString( - "zh-CN", - { minimumFractionDigits: 2 }, - )} - -
- - └── - - - {vh.time} - - {vh.plateNo ? ( - - {vh.plateNo} - - ) : ( - - 无车牌(散车) - - )} - - {renderFleetTag( - vh.fleetCategory === "own", - )} - - - {renderSourceTag(vh.source)} - - {/* 规则:仅内部车辆(羚牛车辆)展示核对状态;外部车辆不参与核对 */} - {vh.fleetCategory === "own" && - vh.verifyStatus && ( - - {renderOrderVerifyTag( - vh.fleetCategory, - vh.verifyStatus, - )} - - )} - - {vh.unitPrice.toFixed(2)} - - {vh.quantityKg.toLocaleString( - "zh-CN", - { minimumFractionDigits: 2 }, - )} - - ¥ - {vh.amountYuan.toLocaleString( - "zh-CN", - { minimumFractionDigits: 2 }, - )} - - - -
-
-
-
- ); -} diff --git a/src/modules/energy/hydrogen-bi-v2/energy-operations-board.css b/src/modules/energy/hydrogen-bi-v2/energy-operations-board.css deleted file mode 100644 index e3ded48..0000000 --- a/src/modules/energy/hydrogen-bi-v2/energy-operations-board.css +++ /dev/null @@ -1,1103 +0,0 @@ -.eob { - --blue: #2f6bff; - --cyan: #62b2bf; - --ink: #16243a; - --muted: #6b7d97; - --line: #dce5f0; - min-height: 100vh; - padding: 0 24px 38px; - background: #f3f6fa; - color: var(--ink); - font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; - box-sizing: border-box; -} -.eob * { - box-sizing: border-box; -} -.eob button, -.eob select { - font: inherit; -} -.eob-hero { - display: flex; - align-items: center; - flex-direction: column; - align-items: stretch; - padding: 4px 14px 1px; - border: 1px solid var(--line); - background: #fff; -} -.eob-hero-nav { - display: flex; - align-items: center; - justify-content: space-between; - margin-top: 4px; - padding-top: 2px; - border-top: 1px solid var(--line); -} -.eob-brand { - display: flex; - align-items: center; - gap: 10px; -} -.eob-brand > span { - display: grid; - width: 34px; - height: 34px; - place-items: center; - border-radius: 11px; - background: var(--blue); - color: #fff; -} -.eob-title-line { - display: flex; - align-items: center; - gap: 8px; -} -.eob h1 { - margin: 0; - font-size: 20px; - line-height: 1.2; -} -.eob-title-line b, -.eob-title-line em { - padding: 3px 8px; - border: 1px solid #9ce0d6; - border-radius: 999px; - background: #edfcf8; - color: #14836f; - font-size: 11px; - font-style: normal; -} -.eob-title-line em { - border-color: #f3ce76; - background: #fff8e8; - color: #a86700; -} -.eob-brand p { - margin: 3px 0 0; - color: var(--muted); - font-size: 11px; -} -.eob-scope, -.eob-tabs, -.eob-vehicle-tabs { - display: flex; - padding: 3px; - border-radius: 10px; - background: #eef3fa; -} -.eob-scope button, -.eob-tabs button, -.eob-vehicle-tabs button { - min-height: 28px; - padding: 0 14px; - border: 0; - border-radius: 8px; - background: transparent; - color: #60728d; - font-weight: 700; - white-space: nowrap; -} -.eob-scope .is-active, -.eob-tabs .is-active, -.eob-vehicle-tabs .is-active { - background: #fff; - color: var(--blue); - box-shadow: 0 1px 4px #21416f1a; -} -.eob-vehicle-tabs { border: 1px solid var(--line); background: #f7f9fc; } -.eob-vehicle-tabs button { display:flex; align-items:center; gap:7px; } -.eob-vehicle-tabs button i { width:6px; height:6px; border-radius:50%; background:#f0a514; } -.eob-vehicle-tabs button:nth-child(2) i { background:#527df0; } -.eob-filters { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin: 10px 0 14px; - padding: 9px 12px; - border: 1px solid var(--line); - background: #fff; -} -.eob-filter-left, -.eob-filter-right { - display: flex; - align-items: center; - gap: 8px; -} -.eob label { - display: flex; - align-items: center; - gap: 7px; - color: var(--muted); - font-size: 12px; -} -.eob-year { height:36px; padding:0 8px; border:1px solid var(--line); border-radius:9px; background:#fff; } -.eob-year select { width:92px; height:30px; padding:0 2px; border:0; background:transparent; font-weight:700; } -.eob-year span { color:#40516a; font-weight:700; } -.eob select, -.eob-icon-button, -.eob-export { - height: 36px; - padding: 0 11px; - border: 1px solid var(--line); - border-radius: 9px; - background: #fff; - color: #40516a; -} -.eob-icon-button, -.eob-export { - display: inline-flex; - align-items: center; - gap: 6px; - cursor: pointer; -} -.eob-export { - border-color: var(--blue); - background: var(--blue); - color: #fff; -} -.eob-state { - display: flex; - min-height: 120px; - align-items: center; - justify-content: center; - gap: 12px; - border: 1px solid var(--line); - border-radius: 14px; - background: #fff; - color: var(--muted); -} -.eob-state.is-error { - color: #b42318; -} -.eob-kpis { - display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: 12px; -} -.eob-kpi { - display: flex; - min-width: 0; - min-height: 140px; - flex-direction: column; - padding: 15px; - border: 1px solid var(--line); - border-radius: 14px; - background: #fff; - color: var(--ink); - text-align: left; - cursor: pointer; -} -.eob-kpi.is-featured { - border-color: #bcd1f4; - background: #eaf2ff; -} -.eob-kpi-head { - display: flex; - align-items: center; - justify-content: space-between; - color: #586d89; - font-size: 12px; - font-weight: 750; -} -.eob-kpi-head > span { display:flex; align-items:center; gap:7px; } -.eob-kpi-head > span small { color:#2f6bff; font-size:9px; } -.eob-kpi-head i { - display: grid; - width: 30px; - height: 30px; - place-items: center; - border-radius: 9px; - background: #e8f8f6; - color: var(--cyan); -} -.eob-kpi > strong { - margin-top: 11px; - white-space: nowrap; -} -.eob-kpi > strong > b { - font: - 800 28px/1.1 ui-monospace, - SFMono-Regular, - Consolas, - monospace; -} -.eob-kpi > strong small { - margin-left: 3px; - color: var(--muted); -} -.eob-kpi p { - margin: auto 0 0; - padding: 8px; - border-radius: 8px; - background: #f5f8fc; - color: #62748d; - font-size: 10px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.eob-kpi-breakdown { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:5px; margin-top:auto; padding:8px; border-radius:8px; background:#f5f8fc; } -.eob-kpi-breakdown span { min-width:0; display:grid; gap:2px; } -.eob-kpi-breakdown small { color:#62748d; font-size:8px; } -.eob-kpi-breakdown b { overflow:hidden; color:#25364e; font-size:9px; text-overflow:ellipsis; white-space:nowrap; } -.eob-diagnosis { - margin-top: 12px; - border: 1px solid var(--line); - border-radius: 12px; - background: #fff; -} -.eob-diagnosis summary { - display: none; -} -.eob-diagnosis > div { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); -} -.eob-diagnosis article { - display: grid; - gap: 3px; - padding: 11px 16px; - border-left: 1px solid var(--line); -} -.eob-diagnosis article:first-child { - border-left: 0; -} -.eob-diagnosis span, -.eob-diagnosis small { - color: var(--muted); - font-size: 10px; -} -.eob-diagnosis strong { - font: - 750 16px ui-monospace, - SFMono-Regular, - Consolas, - monospace; -} -.eob-diagnosis-desktop { - display: grid; - grid-template-columns: 110px repeat(4, minmax(0, 1fr)); - margin-top: 12px; - border: 1px solid var(--line); - border-radius: 12px; - background: #fff; -} -.eob-diagnosis-desktop > b { - display: flex; - align-items: center; - padding: 0 16px; -} -.eob-diagnosis-desktop article { - display: grid; - gap: 3px; - padding: 11px 16px; - border-left: 1px solid var(--line); -} -.eob-diagnosis-desktop span, -.eob-diagnosis-desktop small { - color: var(--muted); - font-size: 10px; -} -.eob-diagnosis-desktop strong { - font: - 750 16px ui-monospace, - SFMono-Regular, - Consolas, - monospace; -} -.eob-charts { - display: grid; - grid-template-columns: 1.1fr 0.9fr; - gap: 14px; - margin-top: 14px; -} -.eob-panel { - padding: 18px; - border: 1px solid var(--line); - border-radius: 16px; - background: #fff; -} -.eob-panel header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} -.eob-panel h2 { - margin: 0; - font-size: 16px; -} -.eob-panel header span { - color: var(--muted); - font-size: 11px; -} -.eob-panel header i { - display: inline-block; - width: 8px; - height: 8px; - margin: 0 5px 0 10px; - border-radius: 2px; -} -.is-blue { - background: var(--blue) !important; -} -.is-cyan { - background: var(--cyan) !important; -} -.is-light-blue { background:#a9c1f5 !important; } -.is-purple { background:#9396e5 !important; } -.eob-bars { - display: flex; - height: 230px; - align-items: flex-end; - gap: 12px; - padding: 34px 8px 22px; - border-bottom: 1px solid #edf1f6; -} -.eob-bars > div { - position: relative; - display: flex; - height: 100%; - min-width: 24px; - flex: 1; - align-items: center; - justify-content: flex-end; - flex-direction: column; -} -.eob-bars > div > span { - margin-bottom: 5px; - color: #53657d; - font: - 600 10px ui-monospace, - monospace; -} -.eob-bars > div > b { - display: flex; - width: min(36px, 75%); - min-height: 2px; - flex-direction: column; - border-radius: 4px 4px 0 0; - overflow: hidden; -} -.eob-bars > div > b i { - display: block; - min-height: 1px; - flex: 1; -} -.eob-bars > div > small { - position: absolute; - bottom: -19px; - color: var(--muted); - font-size: 10px; -} -.eob-ranking { - display: grid; - gap: 14px; - margin: 26px 0 0; - padding: 0; - list-style: none; -} -.eob-ranking li { - display: grid; - grid-template-columns: 24px minmax(110px, 1fr) minmax(80px, 1.4fr) 72px; - align-items: center; - gap: 8px; - font-size: 11px; -} -.eob-ranking li > b { - display: grid; - width: 22px; - height: 22px; - place-items: center; - border-radius: 50%; - background: #eaf2ff; - color: var(--blue); -} -.eob-ranking li > span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.eob-ranking li > i { - height: 8px; - border-radius: 99px; - background: #edf2f8; - overflow: hidden; -} -.eob-ranking li > i em { - display: block; - height: 100%; - background: linear-gradient(90deg, var(--blue), #6c9cff); -} -.eob-ranking li > strong { - text-align: right; - font-family: ui-monospace, monospace; -} -.eob-daily { - margin-top: 0; -} -.eob-daily .eob-bars { - height: 300px; - overflow-x: auto; -} -.eob-daily-table { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 8px; - margin-top: 16px; -} -.eob-daily-table button { - display: grid; - gap: 4px; - padding: 12px; - border: 1px solid var(--line); - border-radius: 10px; - background: #fff; - text-align: left; -} -.eob-daily-table small { - color: var(--muted); -} -.eob-modal { - position: fixed; - inset: 0; - z-index: 100; - display: grid; - place-items: center; - padding: 20px; - background: #0f1d33a8; -} -.eob-modal > section { - width: min(920px, 100%); - max-height: 85vh; - overflow: hidden; - border-radius: 16px; - background: #fff; -} -.eob-modal header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 18px; - border-bottom: 1px solid var(--line); -} -.eob-modal h2, -.eob-modal p { - margin: 0; -} -.eob-modal p { - margin-top: 3px; - color: var(--muted); - font-size: 11px; -} -.eob-modal header button { - display: grid; - width: 40px; - height: 40px; - place-items: center; - border: 0; - border-radius: 10px; - background: #eef3f8; -} -.eob-modal-body { - max-height: calc(85vh - 75px); - overflow: auto; - padding: 16px; -} -.eob-drill-crumbs { - display: flex; - min-height: 44px; - align-items: center; - gap: 5px; - padding: 6px 18px; - border-bottom: 1px solid var(--line); - color: var(--muted); - font-size: 12px; -} -.eob-drill-crumbs button { - padding: 5px 7px; - border: 0; - border-radius: 6px; - background: #eef4ff; - color: var(--blue); - cursor: pointer; -} -.eob-drill-row { - cursor: pointer; -} -.eob-drill-row:hover { - background: #f5f8fc; -} -.eob-drill-row td:first-child { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} -.eob-modal table { - width: 100%; - border-collapse: collapse; - font-size: 12px; -} -.eob-modal th, -.eob-modal td { - padding: 11px; - border-bottom: 1px solid var(--line); - text-align: right; -} -.eob-modal th:first-child, -.eob-modal td:first-child { - text-align: left; -} -@media (max-width: 767px) { - .eob-diagnosis-desktop { - display: none; - } - .eob { - padding: 10px 8px 82px; - } - .eob-hero { - padding: 6px 2px; - border: 0; - background: transparent; - } - .eob-brand > span { - display: none; - } - .eob-title-line { - gap: 5px; - } - .eob h1 { - font-size: 19px; - } - .eob-title-line b, - .eob-title-line em { - display: none; - } - .eob-scope { - padding: 2px; - border-radius: 22px; - } - .eob-scope button { - display: none; - } - .eob-scope button.is-active { - display: block; - min-height: 42px; - border-radius: 21px; - } - .eob-filters { - display: grid; - grid-template-columns: 1fr; - margin: 8px 0 12px; - padding: 9px; - border-radius: 14px; - } - .eob-filter-left, - .eob-filter-right { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 6px; - } - .eob-filter-left label { - display: block; - } - .eob-filter-left label:first-child { - grid-column: 1; - } - .eob-filter-left .eob-tabs { - grid-column: 2/4; - } - .eob-filter-left label:nth-child(3) { - grid-column: 1/-1; - } - .eob-filter-left label:nth-child(3) select { - width: 100%; - } - .eob-filter-left label { - font-size: 0; - } - .eob select, - .eob-icon-button, - .eob-export { - width: 100%; - min-width: 0; - height: 42px; - padding: 0 5px; - font-size: 10px; - } - .eob-icon-button, - .eob-export { - justify-content: center; - } - .eob-filter-right .eob-icon-button, - .eob-filter-right .eob-export { - font-size: 0; - } - .eob-kpis { - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; - } - .eob-kpi { - min-height: 126px; - padding: 12px; - } - .eob-kpi:first-child, - .eob-kpi:nth-child(2) { - grid-column: auto; - } - .eob-kpi:nth-child(3) { - grid-column: 1/-1; - min-height: 108px; - } - .eob-kpi > strong > b { - font-size: clamp(22px, 7vw, 29px); - } - .eob-kpi p { - white-space: normal; - } - .eob-diagnosis { - margin-top: 9px; - } - .eob-diagnosis summary { - display: flex; - min-height: 52px; - align-items: center; - justify-content: space-between; - padding: 0 14px; - font-weight: 800; - list-style: none; - } - .eob-diagnosis summary span { - color: var(--blue); - font-size: 11px; - } - .eob-diagnosis:not([open]) > div { - display: none; - } - .eob-diagnosis > div { - grid-template-columns: repeat(2, minmax(0, 1fr)); - border-top: 1px solid var(--line); - } - .eob-diagnosis article:nth-child(odd) { - border-left: 0; - } - .eob-diagnosis article:nth-child(n + 3) { - border-top: 1px solid var(--line); - } - .eob-charts { - grid-template-columns: 1fr; - gap: 9px; - margin-top: 9px; - } - .eob-panel { - padding: 13px; - border-radius: 14px; - } - .eob-panel h2 { - font-size: 14px; - } - .eob-bars { - height: 210px; - gap: 6px; - padding-inline: 0; - overflow-x: auto; - } - .eob-ranking li { - grid-template-columns: 24px minmax(90px, 1fr) minmax(50px, 0.7fr) 56px; - } - .eob-daily-table { - grid-template-columns: 1fr; - } - .eob-modal { - padding: 0; - } - .eob-modal > section { - width: 100%; - height: 100%; - max-height: none; - border-radius: 0; - } - .eob-modal-body { - max-height: calc(100vh - 75px); - overflow: auto; - } - .eob-modal table { - min-width: 620px; - } -} - -/* Frozen D1-D7 / M1-M8 visual acceptance layer. */ -.eob-mobile-overview, -.eob-mobile-profit, -.eob-mobile-period { - display: none; -} -.eob-hero { - display: grid; - grid-template-columns: 1fr; - padding: 9px 14px 0; -} -.eob-scope { - width: 100%; - margin-top: 8px; - padding: 4px 0; - border-top: 1px solid var(--line); - border-radius: 0; - background: transparent; -} -.eob-scope button { - min-height: 30px; -} -.eob-scope .is-active { - border: 1px solid #d7e3f5; - background: #eaf2ff; - box-shadow: none; -} -.eob-filters { - min-height: 52px; - margin-top: 8px; - padding: 7px 12px; -} -.eob select, -.eob-icon-button, -.eob-export { - height: 32px; - border-radius: 8px; -} -.eob-tabs button { - min-height: 28px; -} -.eob-kpi:nth-child(4) { - border-color: #bcd1f4; - background: #eaf2ff; -} -.eob-diagnosis-desktop { - min-height: 66px; -} -.eob-charts { - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; - margin-top: 12px; -} -.eob-panel { - min-width: 0; - min-height: 290px; - padding: 16px; -} -.eob-panel header > strong { - color: var(--blue); - font: 750 12px ui-monospace, monospace; -} -.eob-finance-bars { - display: flex; - height: 230px; - align-items: flex-end; - gap: 12px; - padding: 32px 8px 22px; - border-bottom: 1px solid #edf1f6; -} -.eob-finance-bars > div { - position: relative; - display: flex; - height: 100%; - min-width: 24px; - flex: 1; - align-items: flex-end; - justify-content: center; -} -.eob-finance-bars > div > span { - display: flex; - width: min(48px, 85%); - height: 100%; - align-items: flex-end; - gap: 4px; -} -.eob-finance-bars i { - width: 50%; - min-height: 2px; - border-radius: 4px 4px 0 0; -} -.eob-finance-bars small { - position: absolute; - bottom: -19px; - color: var(--muted); - font-size: 10px; -} -.eob-regions > div { - display: grid; - gap: 13px; - margin-top: 24px; -} -.eob-regions article { - display: grid; - grid-template-columns: 24px minmax(80px, 0.8fr) minmax(100px, 1.6fr) 52px; - align-items: center; - gap: 8px; - font-size: 11px; -} -.eob-regions article > b { - display: grid; - width: 22px; - height: 22px; - place-items: center; - border-radius: 50%; - background: #e8f8f6; - color: var(--cyan); -} -.eob-regions article > span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.eob-regions article > i { - height: 8px; - overflow: hidden; - border-radius: 99px; - background: #edf2f8; -} -.eob-regions article > i em { - display: block; - height: 100%; - background: linear-gradient(90deg, var(--cyan), #70d2ca); -} -.eob-regions article > strong { - text-align: right; - font-family: ui-monospace, monospace; -} - -@media (max-width: 767px) { - html, - body, - #root, - .eob { - width: 100%; - max-width: 100%; - overflow-x: clip; - } - .eob { - padding: 10px 8px calc(78px + env(safe-area-inset-bottom)); - } - .eob > * { - width: 100%; - min-width: 0; - max-width: 100%; - } - .eob-hero { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - padding: 6px 2px; - } - .eob-brand { - min-width: 0; - } - .eob-brand > div { - min-width: 0; - } - .eob-title-line h1 { - color: var(--ink); - white-space: nowrap; - } - .eob-brand p { - display: block; - color: var(--muted); - white-space: nowrap; - } - .eob-title-line em { - display: inline-flex; - padding: 2px 6px; - font-size: 9px; - } - .eob-title-line b { - display: none; - } - .eob-scope { - width: auto; - margin: 0; - padding: 2px; - border: 0; - border-radius: 22px; - background: #eaf2ff; - } - .eob-scope button { - display: none; - } - .eob-scope button.is-active { - display: block; - min-height: 42px; - padding: 0 12px; - border: 0; - border-radius: 21px; - } - .eob-filters { - display: grid; - min-height: 0; - gap: 6px; - padding: 8px; - } - .eob-filter-left { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 6px; - } - .eob-filter-left label:first-child, - .eob-filter-left .eob-tabs, - .eob-filter-left > select { - grid-column: auto; - } - .eob-filter-left .eob-tabs button { - display: none; - } - .eob-filter-left .eob-tabs button.is-active { - display: block; - width: 100%; - } - .eob-filter-right { - display: grid; - grid-template-columns: minmax(0, 1fr) 42px 42px; - gap: 6px; - } - .eob-filter-left select, - .eob-filter-left .eob-tabs, - .eob-filter-right select, - .eob-icon-button, - .eob-export { - width: 100%; - height: 42px; - min-width: 0; - color: var(--ink); - } - .eob-icon-button, - .eob-export { - padding: 0; - font-size: 0; - justify-content: center; - } - .eob-mobile-overview, - .eob-mobile-profit, - .eob-mobile-period { - display: grid; - } - .eob-mobile-overview { - margin-bottom: 8px; - overflow: hidden; - border: 1px solid var(--line); - border-radius: 14px; - background: #fff; - } - .eob-mobile-overview header { - display: flex; - min-height: 42px; - align-items: center; - justify-content: space-between; - padding: 0 13px; - border-bottom: 1px solid var(--line); - } - .eob-mobile-overview h2 { - margin: 0; - font-size: 15px; - } - .eob-mobile-overview header span { - color: var(--muted); - font-size: 10px; - } - .eob-mobile-totals { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - .eob-mobile-totals button { - display: grid; - min-width: 0; - gap: 8px; - padding: 15px 13px; - border: 0; - background: transparent; - text-align: left; - } - .eob-mobile-totals button + button { - border-left: 1px solid var(--line); - } - .eob-mobile-totals span { - color: var(--muted); - font-size: 11px; - } - .eob-mobile-totals strong { - font: 800 clamp(23px, 7vw, 29px) ui-monospace, monospace; - white-space: nowrap; - } - .eob-mobile-totals small, - .eob-mobile-period small { - margin-left: 3px; - color: var(--muted); - font-size: 10px; - } - .eob-bearer-bar { - display: flex; - height: 8px; - margin: 0 13px 12px; - overflow: hidden; - border-radius: 99px; - background: #dce5f0; - } - .eob-bearer-bar i:nth-child(1) { background: var(--blue); } - .eob-bearer-bar i:nth-child(2) { background: var(--cyan); } - .eob-bearer-bar i:nth-child(3) { background: #79a9e8; } - .eob-bearers { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 6px; - padding: 0 13px 13px; - } - .eob-bearers span { - display: grid; - gap: 3px; - color: var(--muted); - font-size: 10px; - } - .eob-bearers strong { color: var(--ink); font: 700 11px ui-monospace, monospace; } - .eob-bearers small { color: var(--muted); font-size: 9px; } - .eob-mobile-profit { - grid-template-columns: 42px minmax(0, 1fr) minmax(120px, 0.9fr); - align-items: center; - gap: 10px; - margin-bottom: 8px; - padding: 14px; - border: 1px solid var(--line); - border-radius: 14px; - background: #fff; - color: var(--ink); - text-align: left; - } - .eob-mobile-profit > span { display:grid;width:42px;height:42px;place-items:center;border-radius:50%;background:#e8f8f6;color:var(--cyan); } - .eob-mobile-profit > div { display:grid;gap:6px; } - .eob-mobile-profit > div small { color:var(--muted); } - .eob-mobile-profit > div strong { font:800 24px ui-monospace,monospace;white-space:nowrap; } - .eob-mobile-profit > div i { color:var(--muted);font-size:10px;font-style:normal; } - .eob-mobile-profit dl { display:grid;gap:8px;margin:0;padding-left:12px;border-left:1px solid var(--line); } - .eob-mobile-profit dl div { display:grid;gap:2px; } - .eob-mobile-profit dt { color:var(--muted);font-size:10px; } - .eob-mobile-profit dd { margin:0;font:700 10px ui-monospace,monospace;white-space:nowrap; } - .eob-mobile-period { grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-bottom:8px; } - .eob-mobile-period button { display:grid;min-width:0;gap:8px;padding:13px;border:1px solid var(--line);border-radius:14px;background:#fff;text-align:left; } - .eob-mobile-period button > span { color:var(--muted);font-size:11px;font-weight:700; } - .eob-mobile-period strong { font:800 clamp(22px,7vw,28px) ui-monospace,monospace;white-space:nowrap; } - .eob-mobile-period p { margin:0;padding-top:8px;border-top:1px solid var(--line);color:var(--muted);font-size:10px;white-space:nowrap; } - .eob-kpis { display: none; } - .eob-diagnosis { margin-top: 0; margin-bottom: 8px; } - .eob-charts { display:contents; } - .eob-panel { width:100%;min-height:270px;margin-bottom:8px;padding:13px;overflow:hidden; } - .eob-panel header { min-width:0; } - .eob-panel header h2 { min-width:0;font-size:14px;white-space:normal; } - .eob-panel header span { min-width:0;text-align:right; } - .eob-bars, - .eob-finance-bars { width:100%;height:210px;gap:6px;padding-inline:0;overflow:hidden; } - .eob-bars > div, - .eob-finance-bars > div { min-width:0; } - .eob-ranking li, - .eob-regions article { grid-template-columns:22px minmax(80px,1fr) minmax(45px,.7fr) 50px;gap:5px; } -} diff --git a/src/modules/energy/hydrogen-bi-v2/monthly-change.test.ts b/src/modules/energy/hydrogen-bi-v2/monthly-change.test.ts deleted file mode 100644 index 30da204..0000000 --- a/src/modules/energy/hydrogen-bi-v2/monthly-change.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { createServer } from "vite"; -import type { H2BiOverviewResponse } from "./types"; - -const point = (month: string, totalKg: number) => ({ month, totalKg }) as H2BiOverviewResponse["monthly"][number]; - -test("月度环比使用最近两个有效月份且不伪造不可用值", async () => { - const vite = await createServer({ server: { middlewareMode: true }, appType: "custom", optimizeDeps: { noDiscovery: true } }); - try { - const { monthlyChange } = await vite.ssrLoadModule("/src/modules/energy/hydrogen-bi-v2/EnergyOperationsBoard.tsx"); - assert.deepEqual(monthlyChange([point("2026-06", 80), point("2026-08", 120), point("2026-07", 100)]), { value: "+20.0%", detail: "8月较7月" }); - assert.deepEqual(monthlyChange([point("2026-07", 0), point("2026-08", 120)]), { value: "—", detail: "暂不可用" }); - assert.deepEqual(monthlyChange([point("2026-08", 120)]), { value: "—", detail: "暂不可用" }); - assert.deepEqual(monthlyChange([point("2026-07", 100), point("2026-08", Number.NaN)]), { value: "—", detail: "暂不可用" }); - } finally { - await vite.close(); - } -}); diff --git a/src/modules/energy/hydrogen-bi-v2/prototype-adapter.ts b/src/modules/energy/hydrogen-bi-v2/prototype-adapter.ts deleted file mode 100644 index 0342bd5..0000000 --- a/src/modules/energy/hydrogen-bi-v2/prototype-adapter.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { - fetchH2BiDaily, - fetchH2BiDailyTree, - fetchH2BiDrill, - fetchH2BiMeta, - fetchH2BiOverview, -} from "./api"; -import type { H2BiDailyTreeResponse, H2BiDrillQuery, H2BiQuery } from "./types"; - -/** - * The only permitted bridge between the imported prototype DOM and live data. - * It deliberately maps data, never presentation: the original component keeps - * its markup, class names, ordering and interaction hierarchy unchanged. - */ -export async function loadPrototypeOverview(query: H2BiQuery) { - const [meta, overview] = await Promise.all([ - fetchH2BiMeta(), - fetchH2BiOverview(query), - ]); - return { - years: meta.years.map((item) => item.value), - range: overview.range, - watermark: overview.watermark, - kpi: overview.kpis, - monthlyQuantity: overview.monthly.map((item) => ({ - month: item.month, - ownKg: item.lingniuKg, - extKg: item.externalKg, - totalKg: item.totalKg, - })), - monthlyRevenue: overview.monthly.map((item) => ({ - month: item.month, - customerAmount: item.customerRevenue, - // 收入仅来自客户承担订单;成本按账本 settlement_type 分成客户、 - // 我司、其他三段,三段之和严格等于当月总成本。 - cost: item.customerCost, - customerCost: item.customerCost, - companyCost: item.companyCost, - otherCost: item.otherCost, - })), - topStations: overview.topStations.map((item, index) => ({ - rank: index + 1, - ...item, - ownKg: item.lingniuKg, - extKg: item.externalKg, - })), - regions: overview.regions, - stations: overview.stations, - customers: overview.customers, - }; -} - -export async function loadPrototypeDaily(query: H2BiQuery) { - return fetchH2BiDaily(query); -} - -export async function loadPrototypeDailyTree( - date: string, - query: Pick, -): Promise { - return fetchH2BiDailyTree(date, query); -} - -/** - * Keeps the prototype's station -> customer -> vehicle -> record hierarchy, - * while delegating every aggregate and leaf value to the live drill endpoint. - */ -export async function loadPrototypeDrill(query: H2BiDrillQuery) { - return fetchH2BiDrill(query); -} diff --git a/src/modules/energy/hydrogen-bi-v2/prototype-source/EnergyBiAccessGate.tsx b/src/modules/energy/hydrogen-bi-v2/prototype-source/EnergyBiAccessGate.tsx deleted file mode 100644 index 56c101f..0000000 --- a/src/modules/energy/hydrogen-bi-v2/prototype-source/EnergyBiAccessGate.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React, { useState } from 'react'; -import './styles/energy-bi-board.css'; -import { isOssGateSessionAuthed } from '../../common/oss-access-gate'; - -export const ENERGY_BI_PASSWORD = 'lingniu'; -export const ENERGY_BI_AUTH_KEY = 'energy-h2-bi-board-auth-v1'; - -export function isEnergyBiAuthed(): boolean { - return isOssGateSessionAuthed(ENERGY_BI_AUTH_KEY); -} - -export function setEnergyBiAuthed(ok: boolean): void { - try { - if (ok) sessionStorage.setItem(ENERGY_BI_AUTH_KEY, '1'); - else sessionStorage.removeItem(ENERGY_BI_AUTH_KEY); - } catch { - /* ignore */ - } -} - -interface EnergyBiAccessGateProps { - onOk: () => void; -} - -/** 轻门禁:口令 lingniu · 本会话记住(与汇报舱 / 作战室同口径) */ -export const EnergyBiAccessGate: React.FC = ({ onOk }) => { - const [pwd, setPwd] = useState(''); - const [err, setErr] = useState(''); - - const submit = (e: React.FormEvent) => { - e.preventDefault(); - if (pwd.trim() === ENERGY_BI_PASSWORD) { - setEnergyBiAuthed(true); - setErr(''); - onOk(); - return; - } - setErr('口令不对,请重试'); - }; - - return ( -
-
-

ONEOS · 能源 BI

-

氢能经营看板

-

我司成本 · 按日 / 总览 · 单站日报

- - { - setPwd(e.target.value); - if (err) setErr(''); - }} - placeholder="请输入口令" - /> -

- {err} -

- -

内部文件 · 请勿外传

-
-
- ); -}; diff --git a/src/modules/energy/hydrogen-bi-v2/prototype-source/EnergyBiBoardApp.tsx b/src/modules/energy/hydrogen-bi-v2/prototype-source/EnergyBiBoardApp.tsx deleted file mode 100644 index af4fdef..0000000 --- a/src/modules/energy/hydrogen-bi-v2/prototype-source/EnergyBiBoardApp.tsx +++ /dev/null @@ -1,5278 +0,0 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { - Activity, - Calendar, - ChevronDown, - ChevronLeft, - ChevronRight, - Download, - Fuel, - RefreshCw, - Search, - Shield, - TrendingDown, - TrendingUp, - Truck, - Wallet, - X, - Zap, -} from 'lucide-react'; -import { downloadExcelAoa } from '../../common/download-xls'; -import { - SOURCE_LABEL, - companyRowsForStats, - computeHostKpi, - costDimCards, - costDimLabel, - customerAttrAgg, - filterOrders, - formatKg, - formatYuan, - pendingAmount, - stationMonthAgg, - type DimFilter, - unverified, -} from './data/aggregates'; -import { DEFAULT_YEAR, HOST_KPI, MOCK_ORDERS } from './data/mockBoard'; -import { - DAILY_VERIFY_LABEL, - MOCK_DAILY_15DAYS, - SOURCE_TYPE_LABEL, - STATION_TYPE_LABEL, - calculateDailyKpis, - filterDailyDataByFleet, - getDailyDataForRange, - type FleetCategory, - type FleetCategoryFilter, -} from './data/mockDaily'; -import type { FleetScope, HostView, H2OrderRow } from './types'; -import { StationDailyApp } from '../energy-h2-station-daily/StationDailyApp'; -import '../energy-h2-station-daily/styles.css'; -import './styles/energy-bi-board.css'; - -type BoardScope = 'global' | 'station'; -type StatsTab = 'siteMonth' | 'customer'; - -interface BiYearSelectProps { - value: number; - onChange: (year: number) => void; -} - -function BiYearSelect({ value, onChange }: BiYearSelectProps) { - const [isOpen, setIsOpen] = useState(false); - const ref = useRef(null); - - // 提供从 2026 到 2020 年份列表,满足历史多年数据查阅诉求 - const years = [2026, 2025, 2024, 2023, 2022, 2021, 2020]; - - useEffect(() => { - function handleClickOutside(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) { - setIsOpen(false); - } - } - if (isOpen) { - document.addEventListener('mousedown', handleClickOutside); - } - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [isOpen]); - - return ( -
- - - {isOpen && ( -
-
切换数据年份
-
- {years.map((y) => ( - - ))} -
-
- )} -
- ); -} - -/** BI 皮 · 可搜索选择器(禁 V2;穿透筛专用) */ -interface BiSearchSelectOption { - value: string; - label: string; -} - -function BiSearchSelect({ - value, - onChange, - options, - allLabel, - placeholder = '搜索…', - width = 180, - disabled = false, -}: { - value: string; - onChange: (next: string) => void; - options: BiSearchSelectOption[]; - allLabel: string; - placeholder?: string; - width?: number; - disabled?: boolean; -}) { - const [open, setOpen] = useState(false); - const [query, setQuery] = useState(''); - const ref = useRef(null); - const inputRef = useRef(null); - - useEffect(() => { - function onDoc(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); - } - if (open) document.addEventListener('mousedown', onDoc); - return () => document.removeEventListener('mousedown', onDoc); - }, [open]); - - useEffect(() => { - if (open) { - setQuery(''); - requestAnimationFrame(() => inputRef.current?.focus()); - } - }, [open]); - - const selectedLabel = - value === 'all' ? allLabel : options.find((o) => o.value === value)?.label || allLabel; - - const filtered = useMemo(() => { - const q = query.trim().toLowerCase(); - if (!q) return options; - return options.filter((o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q)); - }, [options, query]); - - return ( -
- - {open && !disabled && ( -
-
- - setQuery(e.target.value)} - placeholder={placeholder} - onClick={(e) => e.stopPropagation()} - /> -
-
- - {filtered.map((o) => ( - - ))} - {filtered.length === 0 &&
无匹配项
} -
-
- )} -
- ); -} - -// 站加氢汇总假数据 (带省份归属,与用户截图高保真100%对齐) -const MOCK_STATION_SUMMARY_LIST = [ - { rank: 1, name: '嘉兴中石化滨海加氢站', province: '浙江省', kgT: '243.66', kgPct: 35.0, incomeWan: '52.66', incomePct: 7.1 }, - { rank: 2, name: '嘉兴嘉锦加氢站', province: '浙江省', kgT: '182.89', kgPct: 26.2, incomeWan: '119.53', incomePct: 16.1 }, - { rank: 3, name: '嘉兴嘉燃加氢站', province: '浙江省', kgT: '28.23', kgPct: 4.0, incomeWan: '61.47', incomePct: 8.3 }, - { rank: 4, name: '桐乡中石化绿能加氢站', province: '浙江省', kgT: '26.08', kgPct: 3.7, incomeWan: '35.76', incomePct: 4.8 }, - { rank: 5, name: '成都中石化天府机场高速北站加氢站', province: '四川省', kgT: '22.93', kgPct: 3.3, incomeWan: '68.62', incomePct: 9.2 }, - { rank: 6, name: '花桥中石油加氢站', province: '江苏省', kgT: '19.71', kgPct: 2.8, incomeWan: '35.15', incomePct: 4.7 }, - { rank: 7, name: '常熟嘉化加氢站', province: '江苏省', kgT: '15.56', kgPct: 2.2, incomeWan: '25.81', incomePct: 3.5 }, - { rank: 8, name: '嘉善中石化站前路加氢站', province: '浙江省', kgT: '14.61', kgPct: 2.1, incomeWan: '25.34', incomePct: 3.4 }, - { rank: 9, name: '嘉兴东方港湾加氢站', province: '浙江省', kgT: '13.45', kgPct: 1.9, incomeWan: '7.01', incomePct: 0.9 }, - { rank: 10, name: '成都中石化天府机场高速南站加氢站', province: '四川省', kgT: '12.86', kgPct: 1.8, incomeWan: '38.46', incomePct: 5.2 }, - { rank: 11, name: '成都国氢华通加氢站', province: '四川省', kgT: '12.09', kgPct: 1.7, incomeWan: '36.26', incomePct: 4.9 }, - { rank: 12, name: '广州新锋交通联新加氢站', province: '广东省', kgT: '9.33', kgPct: 1.3, incomeWan: '13.49', incomePct: 1.8 }, - { rank: 13, name: '乌鲁木齐隆盛达沙坪加氢站', province: '新疆维吾尔自治区', kgT: '9.33', kgPct: 1.3, incomeWan: '21.12', incomePct: 2.8 }, - { rank: 14, name: '佛山豪石油加氢站', province: '广东省', kgT: '8.96', kgPct: 1.3, incomeWan: '26.6', incomePct: 3.6 }, - { rank: 15, name: '佛南海羚牛加氢站', province: '广东省', kgT: '7.10', kgPct: 1.0, incomeWan: '3.29', incomePct: 0.4 }, - { rank: 16, name: '佛山中石化佛西加氢站', province: '广东省', kgT: '5.83', kgPct: 0.8, incomeWan: '20.32', incomePct: 2.7 }, - { rank: 17, name: '广州中石化东明三路加氢站', province: '广东省', kgT: '5.62', kgPct: 0.8, incomeWan: '5.73', incomePct: 0.8 }, - { rank: 18, name: '常熟AP银河路加氢站', province: '江苏省', kgT: '4.98', kgPct: 0.7, incomeWan: '20.06', incomePct: 2.7 }, - { rank: 19, name: '武汉中石化革新加氢站', province: '湖北省', kgT: '3.95', kgPct: 0.6, incomeWan: '9.45', incomePct: 1.3 }, - { rank: 20, name: '韶关韶钢加氢站', province: '广东省', kgT: '3.72', kgPct: 0.5, incomeWan: '10.81', incomePct: 1.5 }, - { rank: 21, name: '成都博能加氢站', province: '四川省', kgT: '3.49', kgPct: 0.5, incomeWan: '10.21', incomePct: 1.4 }, - { rank: 22, name: '无锡润硕氢能加氢站', province: '江苏省', kgT: '3.22', kgPct: 0.5, incomeWan: '11.98', incomePct: 1.6 }, - { rank: 23, name: '上海安亭加氢站', province: '上海市', kgT: '3.10', kgPct: 0.4, incomeWan: '9.82', incomePct: 1.3 }, - { rank: 24, name: '昆山千灯加氢站', province: '江苏省', kgT: '2.88', kgPct: 0.4, incomeWan: '8.90', incomePct: 1.2 }, - { rank: 25, name: '宁波港区示范加氢站', province: '浙江省', kgT: '2.45', kgPct: 0.3, incomeWan: '7.65', incomePct: 1.0 }, -]; - -// 客户账单汇总假数据 (Top 30 与用户截图高保真100%对齐) -const MOCK_CUSTOMER_SUMMARY_LIST = [ - { rank: 1, name: '嘉兴市乍浦港口经营有限公司', bearer: 'cust' as const, kgT: '288.37', costWan: '807.94', receivable: '¥1,987 元' }, - { rank: 2, name: '嘉兴益顺冷链物流有限公司', bearer: 'cust' as const, kgT: '38.93', costWan: '136.83', receivable: '¥66.25 万元' }, - { rank: 3, name: '嘉兴智奇供应链管理有限公司', bearer: 'cust' as const, kgT: '35.45', costWan: '101.73', receivable: '¥14.44 万元' }, - { rank: 4, name: '车辆异动', bearer: 'cust' as const, kgT: '28.10', costWan: '92.96', receivable: '¥2,503 元' }, - { rank: 5, name: '四川群彬物流有限公司', bearer: 'cust' as const, kgT: '23.35', costWan: '69.98', receivable: '¥69.98 万元' }, - { rank: 6, name: '浙江洋井供应链管理有限公司', bearer: 'cust' as const, kgT: '18.62', costWan: '61.08', receivable: '¥321 元' }, - { rank: 7, name: '无锡铭康物流有限公司-1', bearer: 'lingniu' as const, kgT: '16.89', costWan: '58.38', receivable: '¥0 元' }, - { rank: 8, name: '上海明纳物流有限公司', bearer: 'lingniu' as const, kgT: '12.44', costWan: '41.4', receivable: '¥0 元' }, - { rank: 9, name: '嘉兴中外运物流有限公司', bearer: 'lingniu' as const, kgT: '11.39', costWan: '31.94', receivable: '¥0 元' }, - { rank: 10, name: '重庆金时源供应链有限公司', bearer: 'cust' as const, kgT: '11.18', costWan: '27.96', receivable: '¥27.96 万元' }, - { rank: 11, name: '四川拱照物流有限公司', bearer: 'cust' as const, kgT: '10.34', costWan: '31', receivable: '¥31 万元' }, - { rank: 12, name: '无锡铭康物流有限公司', bearer: 'lingniu' as const, kgT: '9.21', costWan: '31.72', receivable: '¥0 元' }, - { rank: 13, name: '嘉兴羚利供应链科技有限公司', bearer: 'cust' as const, kgT: '8.62', costWan: '24.13', receivable: '¥25.85 万元' }, - { rank: 14, name: '宁波港集装箱运输有限公司嘉兴分公司', bearer: 'cust' as const, kgT: '8.09', costWan: '22.66', receivable: '¥23.01 万元' }, - { rank: 15, name: '成都诺和物流有限公司', bearer: 'cust' as const, kgT: '6.67', costWan: '19.99', receivable: '¥19.99 万元' }, - { rank: 16, name: '嘉兴市飞宇物流有限公司', bearer: 'cust' as const, kgT: '6.49', costWan: '18.22', receivable: '¥6,483 元' }, - { rank: 17, name: '嘉兴港区众通快递有限公司', bearer: 'cust' as const, kgT: '6.27', costWan: '17.55', receivable: '¥18.81 万元' }, - { rank: 18, name: '浙江集佑供应链有限公司', bearer: 'cust' as const, kgT: '6.20', costWan: '17.35', receivable: '¥18.59 万元' }, - { rank: 19, name: '日邮物流(中国)有限公司', bearer: 'cust' as const, kgT: '5.90', costWan: '22.11', receivable: '¥22.2 万元' }, - { rank: 20, name: '四川邦达蜀运供应链管理有限公司', bearer: 'cust' as const, kgT: '5.21', costWan: '15.63', receivable: '¥15.65 万元' }, - { rank: 21, name: '嘉兴古道物流有限公司', bearer: 'cust' as const, kgT: '5.18', costWan: '14.49', receivable: '¥15.53 万元' }, - { rank: 22, name: '宁波乐驰物流有限公司', bearer: 'cust' as const, kgT: '5.01', costWan: '14.01', receivable: '¥14.1 万元' }, - { rank: 23, name: '广东清运物流专线', bearer: 'cust' as const, kgT: '4.82', costWan: '13.50', receivable: '¥13.50 万元' }, - { rank: 24, name: '顺丰冷运嘉兴分线', bearer: 'cust' as const, kgT: '4.21', costWan: '11.78', receivable: '¥11.80 万元' }, - { rank: 25, name: '极兔速递冷链事业部', bearer: 'cust' as const, kgT: '3.95', costWan: '11.06', receivable: '¥11.06 万元' }, - { rank: 26, name: '武汉捷运货运有限公司', bearer: 'cust' as const, kgT: '3.62', costWan: '10.13', receivable: '¥10.15 万元' }, - { rank: 27, name: '成都天府物流二部', bearer: 'cust' as const, kgT: '3.11', costWan: '8.70', receivable: '¥8.70 万元' }, - { rank: 28, name: '广州黄埔冷链车队', bearer: 'cust' as const, kgT: '2.85', costWan: '7.98', receivable: '¥8.00 万元' }, - { rank: 29, name: '常熟物流储运中心', bearer: 'cust' as const, kgT: '2.40', costWan: '6.72', receivable: '¥6.72 万元' }, - { rank: 30, name: '无锡灵通运输公司', bearer: 'cust' as const, kgT: '2.10', costWan: '5.88', receivable: '¥5.90 万元' }, -]; - -interface OverviewTrendsProps { - year: number; - fleetScope: FleetScope; - verifyScope: 'all' | 'verified'; - onOpenDrill: (label: string) => void; - onOpenCustomerBill: (custName: string) => void; - onOpenStationBill: (stName: string, province: string) => void; -} - -function OverviewTrendsDashboard({ year, fleetScope, verifyScope, onOpenDrill, onOpenCustomerBill, onOpenStationBill }: OverviewTrendsProps) { - // 月度加氢量数据 (根据年份、车辆范围、核对范围加权) - const monthlyData = useMemo(() => { - const is2026 = year === 2026; - const is2025 = year === 2025; - const factor = is2026 ? 1 : is2025 ? 0.85 : 0.7; - // 仅已核对:外部车在示意数据中不参与核对 → 外部归零;羚牛约 3/4 已核 - const ownVerifyFactor = verifyScope === 'verified' ? 0.75 : 1; - const extVerifyFactor = verifyScope === 'verified' ? 0 : 1; - - const baseMonths = [ - { m: '1月', own: 56800, ext: 28400 }, - { m: '2月', own: 34600, ext: 17400 }, - { m: '3月', own: 75200, ext: 37600 }, - { m: '4月', own: 90000, ext: 45000 }, - { m: '5月', own: 85300, ext: 42700 }, - { m: '6月', own: 78600, ext: 39400 }, - { m: '7月', own: 81300, ext: 40700 }, - { m: '8月', own: 18600, ext: 9400 }, - ]; - - return baseMonths.map((item) => { - let ownKg = Math.round(item.own * factor * ownVerifyFactor); - let extKg = Math.round(item.ext * factor * extVerifyFactor); - if (fleetScope === 'own') extKg = 0; - if (fleetScope === 'external') ownKg = 0; - const totalKg = ownKg + extKg; - return { month: item.m, ownKg, extKg, totalKg }; - }); - }, [year, fleetScope, verifyScope]); - - const maxMonthlyKg = useMemo(() => { - return Math.max(...monthlyData.map((d) => d.totalKg), 1); - }, [monthlyData]); - - // 基础客户列表池,用于计算月度客户加氢金额排行 - const baseCustomers = [ - { name: '嘉兴市乍浦港口经营有限公司', ratio: 0.408 }, - { name: '嘉兴益顺冷链物流有限公司', ratio: 0.176 }, - { name: '嘉兴智奇供应链管理有限公司', ratio: 0.118 }, - { name: '四川群彬物流有限公司', ratio: 0.078 }, - { name: '浙江洋井供应链管理有限公司', ratio: 0.053 }, - { name: '重庆金时源供应链有限公司', ratio: 0.039 }, - { name: '四川拱照物流有限公司', ratio: 0.030 }, - { name: '嘉兴羚利供应链科技有限公司', ratio: 0.027 }, - { name: '宁波港集装箱运输嘉兴分公司', ratio: 0.023 }, - { name: '成都诺和物流有限公司', ratio: 0.015 }, - { name: '嘉兴市飞宇物流有限公司', ratio: 0.012 }, - { name: '嘉兴港区众通快递有限公司', ratio: 0.010 }, - { name: '浙江集佑供应链有限公司', ratio: 0.008 }, - { name: '日邮物流(中国)有限公司', ratio: 0.003 }, - ]; - - // 月度收支对比数据及客户加氢金额明细 & 成本支出结构明细 - const monthlyRevenueData = useMemo(() => { - const is2026 = year === 2026; - const factor = is2026 ? 1 : 0.8; - const fleetFactor = fleetScope === 'all' ? 1 : fleetScope === 'own' ? 0.67 : 0.33; - const verifyFactor = verifyScope === 'verified' ? (fleetScope === 'external' ? 0 : 0.75) : 1; - const scale = factor * fleetFactor * verifyFactor; - const base = [ - { m: '1月', income: 82000, cost: 78000 }, - { m: '2月', income: 38000, cost: 36000 }, - { m: '3月', income: 98000, cost: 92000 }, - { m: '4月', income: 105000, cost: 99000 }, - { m: '5月', income: 112000, cost: 104000 }, - { m: '6月', income: 118000, cost: 109000 }, - { m: '7月', income: 115000, cost: 108000 }, - { m: '8月', income: 16500, cost: 15800 }, - ]; - - return base.map((b) => { - const income = Math.round(b.income * scale); - const cost = Math.round(b.cost * scale); - - // 计算每个客户当月收入金额 - const rawList = baseCustomers.map((c) => ({ - name: c.name, - amount: Math.round(income * c.ratio), - })); - - // 按从高到低排序 - rawList.sort((x, y) => y.amount - x.amount); - - // 截取 TOP9 - const top9 = rawList.slice(0, 9); - const restList = rawList.slice(9); - const restAmount = restList.reduce((sum, item) => sum + item.amount, 0); - - // 计算成本支出结构细项 (包氢、我司承担、物流、运维异动、运维调拨) - const costDetails = [ - { label: '包氢项目', amount: Math.round(cost * 0.36) }, - { label: '我司承担', amount: Math.round(cost * 0.32) }, - { label: '物流成本', amount: Math.round(cost * 0.18) }, - { label: '运维异动', amount: Math.round(cost * 0.08) }, - { label: '运维调拨', amount: Math.round(cost * 0.06) }, - ]; - - return { - m: b.m, - income, - cost, - top9, - restAmount, - restCount: restList.length, - costDetails, - }; - }); - }, [year, fleetScope, verifyScope]); - - const maxRevenueVal = useMemo(() => { - return Math.max(...monthlyRevenueData.flatMap((d) => [d.income, d.cost]), 1); - }, [monthlyRevenueData]); - - // Top5 站列表 (带内部 vs 外部堆积;跟随车辆/核对筛选) - const topStations = useMemo(() => { - const ownVerifyFactor = verifyScope === 'verified' ? 0.75 : 1; - const extVerifyFactor = verifyScope === 'verified' ? 0 : 1; - const raw = [ - { rank: 1, name: '嘉兴中石化滨海加氢站', ownKg: 163250, extKg: 80411 }, - { rank: 2, name: '嘉兴嘉锦加氢站', ownKg: 128020, extKg: 54869 }, - { rank: 3, name: '嘉兴嘉燃加氢站', ownKg: 18350, extKg: 9884 }, - { rank: 4, name: '桐乡中石化绿能加氢站', ownKg: 15648, extKg: 10432 }, - { rank: 5, name: '成都中石化天府机场北站', ownKg: 16050, extKg: 6879 }, - ]; - return raw - .map((st) => { - let ownKg = Math.round(st.ownKg * ownVerifyFactor); - let extKg = Math.round(st.extKg * extVerifyFactor); - if (fleetScope === 'own') extKg = 0; - if (fleetScope === 'external') ownKg = 0; - const val = ownKg + extKg; - return { ...st, ownKg, extKg, val, pct: 100 }; - }) - .map((st, _, arr) => { - const maxVal = Math.max(...arr.map((x) => x.val), 1); - return { ...st, pct: Math.round((st.val / maxVal) * 100) }; - }); - }, [fleetScope, verifyScope]); - - // 区域维度控制: 按市 ('city') | 按省 ('province') - const [regionGranularity, setRegionGranularity] = useState<'province' | 'city'>('city'); - - // 省份筛选控制: 'all' | '浙江省' | '四川省' | '广东省' | '江苏省' | '湖北省' 等 - const [selectedProvince, setSelectedProvince] = useState('all'); - - // 已有加氢站的省份去重列表 - const availableProvinces = useMemo(() => { - const list: string[] = ['all']; - MOCK_STATION_SUMMARY_LIST.forEach((st) => { - if (st.province && !list.includes(st.province)) { - list.push(st.province); - } - }); - return list; - }, []); - - // 根据选定省份精准过滤加氢站列表 - const filteredStationList = useMemo(() => { - if (selectedProvince === 'all') return MOCK_STATION_SUMMARY_LIST; - return MOCK_STATION_SUMMARY_LIST.filter((st) => st.province === selectedProvince); - }, [selectedProvince]); - - // 根据过滤结果计算总站数 (全国 65 站基准,按比例联动) - const stationCountDisplay = useMemo(() => { - if (selectedProvince === 'all') return '共 65 站'; - if (selectedProvince === '浙江省') return '共 28 站'; - if (selectedProvince === '广东省') return '共 16 站'; - if (selectedProvince === '四川省') return '共 11 站'; - if (selectedProvince === '江苏省') return '共 7 站'; - return `共 ${filteredStationList.length} 站`; - }, [selectedProvince, filteredStationList]); - - // 按市区域占比数据 (规范地级市名称) - const cityRegions = [ - { label: '嘉兴市', pct: '65.2%', color: '#0284c7', dashArray: '155 238', dashOffset: '0' }, - { label: '成都市', pct: '7.4%', color: '#38bdf8', dashArray: '18 238', dashOffset: '-156' }, - { label: '佛山市', pct: '3.7%', color: '#10b981', dashArray: '9 238', dashOffset: '-175' }, - { label: '昆山市', pct: '2.8%', color: '#f59e0b', dashArray: '7 238', dashOffset: '-185' }, - { label: '常熟市', pct: '2.2%', color: '#8b5cf6', dashArray: '5 238', dashOffset: '-193' }, - { label: '广州市', pct: '2.1%', color: '#ec4899', dashArray: '5 238', dashOffset: '-199' }, - { label: '深圳市', pct: '1.9%', color: '#06b6d4', dashArray: '4 238', dashOffset: '-205' }, - { label: '无锡市', pct: '1.9%', color: '#84cc16', dashArray: '4 238', dashOffset: '-210' }, - { label: '其他城市', pct: '12.7%', color: '#94a3b8', dashArray: '30 238', dashOffset: '-215' }, - ]; - - // 按省区域占比数据 - const provinceRegions = [ - { label: '浙江省', pct: '73.2%', color: '#0284c7', dashArray: '175 238', dashOffset: '0' }, - { label: '四川省', pct: '11.8%', color: '#38bdf8', dashArray: '28 238', dashOffset: '-176' }, - { label: '广东省', pct: '7.5%', color: '#10b981', dashArray: '18 238', dashOffset: '-205' }, - { label: '江苏省', pct: '5.4%', color: '#f59e0b', dashArray: '13 238', dashOffset: '-224' }, - { label: '其他省份', pct: '2.1%', color: '#94a3b8', dashArray: '5 238', dashOffset: '-238' }, - ]; - - const activeRegions = regionGranularity === 'province' ? provinceRegions : cityRegions; - - return ( -
- {/* 1. 月度加氢量趋势柱图 */} -
-
-
{year} 年月度加氢量
-
- - - 内部客户 - - - - 外部客户 - - - 统计范围:{year === 2026 ? '2026-01-01 至 2026-08-08' : `${year}-01-01 至 ${year}-12-31`} · 单位 Kg - -
-
- -
- {monthlyData.map((d) => { - const heightPct = Math.min(100, Math.round((d.totalKg / maxMonthlyKg) * 100)); - const ownRatio = d.totalKg > 0 ? Math.round((d.ownKg / d.totalKg) * 100) : 60; - const extRatio = Math.max(0, 100 - ownRatio); - - return ( -
onOpenDrill(`${year}年${d.month}加氢量`)} - style={{ cursor: 'pointer' }} - title="点击钻取该月各加氢站内部/外部客户加氢量" - > - {/* 悬浮柱状图时显示内部客户、外部客户加氢量卡片 */} -
-
- {year}年{d.month} -
-
- - - 内部客户 - - - {d.ownKg.toLocaleString('zh-CN')} Kg - -
-
- - - 外部客户 - - - {d.extKg.toLocaleString('zh-CN')} Kg - -
-
- - 月度合计 - - - {d.totalKg.toLocaleString('zh-CN')} Kg - -
-
- -
{(d.totalKg / 1000).toFixed(1)}k
-
-
-
-
-
{d.month}
-
- ); - })} -
-
- - {/* 2. 月度收支对比柱图 */} -
-
-
{year} 年月度收支对比
-
- - - 客户收入 - - - - 成本支出 - - - 统计范围:{year === 2026 ? '2026-01-01 至 2026-08-08' : `${year}-01-01 至 ${year}-12-31`} · 单位 元 - -
-
- -
- {monthlyRevenueData.map((d) => { - const incPct = Math.min(100, Math.round((d.income / maxRevenueVal) * 100)); - const costPct = Math.min(100, Math.round((d.cost / maxRevenueVal) * 100)); - - return ( -
-
-
onOpenDrill(`${year}年${d.m}成本支出`)} - title="点击钻取该月各加氢站成本支出" - > - {/* 悬浮成本支出时,显示包氢、物流、运维异动等成本结构明细 */} -
-
- {year}年{d.m} 成本支出明细 - 成本构成 -
- -
- {d.costDetails.map((item) => ( -
- - - {item.label} - - - ¥{item.amount.toLocaleString('zh-CN')} - -
- ))} -
- -
- 成本合计 - - ¥{d.cost.toLocaleString('zh-CN')} - -
-
-
-
onOpenDrill(`${year}年${d.m}客户收入`)} - title="点击钻取该月各加氢站客户收入" - > - {/* 悬浮客户收入时,显示按金额从高到低排列的客户明细 (TOP9 + 其他客户) */} -
-
- {year}年{d.m} 客户加氢收入明细 - 金额高→低 -
- -
- {d.top9.map((item, idx) => ( -
- - {idx + 1}. {item.name} - - - ¥{item.amount.toLocaleString('zh-CN')} - -
- ))} - - {d.restCount > 0 && ( -
- - 10. 其他客户 ({d.restCount}家) - - - ¥{d.restAmount.toLocaleString('zh-CN')} - -
- )} -
- -
- 收入合计 - - ¥{d.income.toLocaleString('zh-CN')} - -
-
-
-
-
{d.m}
-
- ); - })} -
-
- - {/* 3 & 4. 下方并排:Top5 站加氢量 + 各区域加氢占比 */} -
- {/* Top5 站 */} -
-
-
加氢站加氢量 Top5
-
- - - 内部客户 - - - - 外部客户 - - - 统计范围:{year === 2026 ? '2026-01-01 至 2026-08-08' : `${year}-01-01 至 ${year}-12-31`} · 单位 Kg - -
-
- -
- {topStations.map((st) => { - const ownRatio = Math.round((st.ownKg / st.val) * 100); - const extRatio = 100 - ownRatio; - - return ( -
onOpenDrill(`加氢站客户量:${st.name}`)} - style={{ cursor: 'pointer' }} - title="点击钻取该加氢站内部/外部客户加氢总量" - > - 2 ? 'is-sub' : ''}`}>{st.rank} - - {st.name} - -
- {/* 精细高保真 Hover 悬浮弹出卡片 */} -
-
{st.name}
-
- - - 内部客户 - - - {st.ownKg.toLocaleString('zh-CN')} Kg ({ownRatio}%) - -
-
- - - 外部客户 - - - {st.extKg.toLocaleString('zh-CN')} Kg ({extRatio}%) - -
-
- 加氢总量 - - {st.val.toLocaleString('zh-CN')} Kg - -
-
- -
-
-
-
-
- {st.val.toLocaleString('zh-CN')} -
- ); - })} -
-
- - {/* 各区域加氢占比 (支持按省 / 按市快速切换) */} -
-
-
各区域加氢占比
-
- - -
-
- -
-
- - - {activeRegions.map((reg) => ( - - onOpenDrill( - `区域${regionGranularity === 'city' ? '市' : '省'}:${reg.label}`, - ) - } - > - {`点击钻取${reg.label}各加氢站加氢总量与占比`} - - ))} - -
-
年合计
-
697.17T
-
-
- -
- {activeRegions.map((reg) => ( -
- onOpenDrill(`区域${regionGranularity === 'city' ? '市' : '省'}:${reg.label}`) - } - style={{ cursor: 'pointer' }} - title={`点击钻取${reg.label}各加氢站加氢总量与占比`} - > -
- - {reg.label} -
- {reg.pct} -
- ))} -
-
-
-
- - {/* 5. 趋势图下方:加氢站加氢汇总表 (支持区域按省筛选切换) */} -
-
-
-
加氢站加氢汇总
- {/* 区域省份切换控制 (仅展示已有加氢站的省份) */} -
- {availableProvinces.map((prov) => ( - - ))} -
-
-
- 统计范围:{year === 2026 ? '2026-01-01 至 2026-08-08' : `${year}-01-01 至 ${year}-12-31`} · {stationCountDisplay} -
-
- -
- - - - - - - - - - - - - - {filteredStationList.map((st, idx) => ( - onOpenStationBill(st.name, st.province)} - style={{ cursor: 'pointer' }} - title="点击钻取:加氢量 / 占比 / 氢费收入 / 收入占比" - > - - - - - - - - - ))} - -
#加氢站(点击钻取)所属省份加氢量占比氢费收入收入占比
{idx + 1} - {st.name} 钻取 › - - - {st.province} - - - {st.kgT} T - -
-
-
-
- {st.kgPct.toFixed(1)}% -
-
- ¥{st.incomeWan} 万元 - -
-
-
-
- {st.incomePct.toFixed(1)}% -
-
-
-
- - {/* 6. 趋势图下方:客户账单汇总表 (Top 30) */} -
-
-
- 客户账单汇总 - - (已收 / 未收:等待客户能源账户和对账单打通后获取) - - - (已收未收打通中) - -
-
- 统计范围:{year === 2026 ? '2026-01-01 至 2026-08-08' : `${year}-01-01 至 ${year}-12-31`} · Top 30 -
-
- -
- - - - - - - - - - - - - - - {MOCK_CUSTOMER_SUMMARY_LIST.map((cust) => ( - onOpenCustomerBill(cust.name)} - style={{ cursor: 'pointer' }} - title="点击钻取:承担方 / 加氢量 / 成本支出 / 应收 / 已收 / 未收" - > - - - - - - - - - - ))} - -
#客户(点击钻取)承担方加氢量成本支出应收已收未收
{cust.rank} - {cust.name} 钻取 › - - - {cust.bearer === 'cust' ? '客户' : '羚牛'} - - - {cust.kgT} T - - ¥{cust.costWan} 万元 - - {cust.receivable} - - - 敬请期待 - - - - 敬请期待 - -
-
-
-
- ); -} - -/** - * 嵌入 bi-next `#hydrogen/overview`: - * - 壳 / KPI / 洞察对齐宿主 zip 视觉 - * - 独立功能块:三维度 + 站月/客户汇总 + 订单明细 · 禁用 OneOS V2 - */ -export const EnergyBiBoardApp: React.FC = () => { - const [boardScope, setBoardScope] = useState('global'); - const [hostView, setHostView] = useState('overview'); - const [year, setYear] = useState(DEFAULT_YEAR); - const [fleetScope, setFleetScope] = useState('all'); - const [verifyScope, setVerifyScope] = useState<'all' | 'verified'>('all'); - const [dimFilter, setDimFilter] = useState(null); - const [statsTab, setStatsTab] = useState('siteMonth'); - const [stationId, setStationId] = useState(null); - const [stationLabel, setStationLabel] = useState(null); - const [customerId, setCustomerId] = useState(null); - const [customerLabel, setCustomerLabel] = useState(null); - - // KPI 点击下钻 Modal 状态 - const [kpiDrillType, setKpiDrillType] = useState(null); - // 头部加氢站占比 → 加氢量排名下拉 - const [stationRankOpen, setStationRankOpen] = useState(false); - const stationRankRef = useRef(null); - - // 客户账单专属下钻 Modal 状态 (客户 → 日期 → 车牌加氢记录) - const [selectedBillCustomer, setSelectedBillCustomer] = useState(null); - - // 加氢站账单专属下钻 Modal 状态 (加氢站 → 所有日期的加氢量、占比、氢费收入、收入占比) - const [selectedStationForDrill, setSelectedStationForDrill] = useState<{ name: string; province: string } | null>(null); - - // 按日视角日期区间状态 (提升至顶层供标题旁时间范围联动) - const [dailyStartDate, setDailyStartDate] = useState('2026-07-25'); - const [dailyEndDate, setDailyEndDate] = useState('2026-08-08'); - - // 全局看板时间范围(单站模式不展示:维度不同,由站内查询日期自管) - const timeRangeLabel = '统计时间范围'; - const timeRangeText = useMemo(() => { - if (hostView === 'daily') { - return `${dailyStartDate} 至 ${dailyEndDate}`; - } - return year === 2026 ? '2026-01-01 至 2026-08-08 11:25' : `${year}-01-01 至 ${year}-12-31 23:59`; - }, [hostView, dailyStartDate, dailyEndDate, year]); - - const clearEntity = () => { - setStationId(null); - setStationLabel(null); - setCustomerId(null); - setCustomerLabel(null); - }; - - const rows = useMemo( - () => filterOrders(MOCK_ORDERS, year, verifyScope, fleetScope), - [year, verifyScope, fleetScope], - ); - const hostKpi = useMemo( - () => computeHostKpi(rows, year, MOCK_ORDERS, HOST_KPI), - [rows, year], - ); - - // 加氢站加氢量排名(高→低),跟随年份/车辆/核对筛选 - const stationRankList = useMemo(() => { - const yearFactor = year === 2026 ? 1 : year === 2025 ? 0.85 : 0.7; - const fleetFactor = fleetScope === 'all' ? 1 : fleetScope === 'own' ? 0.67 : 0.33; - const verifyFactor = verifyScope === 'verified' ? (fleetScope === 'external' ? 0 : 0.75) : 1; - const scale = yearFactor * fleetFactor * verifyFactor; - const list = MOCK_STATION_SUMMARY_LIST.map((st) => ({ - name: st.name, - province: st.province, - kg: Math.round(parseFloat(st.kgT) * 1000 * scale), - })) - .filter((st) => st.kg > 0) - .sort((a, b) => b.kg - a.kg); - const maxKg = list[0]?.kg || 1; - const totalKg = list.reduce((s, x) => s + x.kg, 0) || 1; - return list.map((st, i) => ({ - ...st, - rank: i + 1, - barPct: Math.round((st.kg / maxKg) * 100), - sharePct: Math.round((st.kg / totalKg) * 1000) / 10, - })); - }, [year, fleetScope, verifyScope]); - - const top5SharePct = useMemo(() => { - const top5 = stationRankList.slice(0, 5).reduce((s, x) => s + x.kg, 0); - const total = stationRankList.reduce((s, x) => s + x.kg, 0) || 1; - return Math.round((top5 / total) * 1000) / 10; - }, [stationRankList]); - - // 月度波动洞察副文案:当月 / 峰值月 / 月均(与图表月度口径一致,跟随筛选) - const monthFluctuationDesc = useMemo(() => { - const yearFactor = year === 2026 ? 1 : year === 2025 ? 0.85 : 0.7; - const ownVerifyFactor = verifyScope === 'verified' ? 0.75 : 1; - const extVerifyFactor = verifyScope === 'verified' ? 0 : 1; - const baseMonths = [ - { m: '1月', own: 56800, ext: 28400 }, - { m: '2月', own: 34600, ext: 17400 }, - { m: '3月', own: 75200, ext: 37600 }, - { m: '4月', own: 90000, ext: 45000 }, - { m: '5月', own: 85300, ext: 42700 }, - { m: '6月', own: 78600, ext: 39400 }, - { m: '7月', own: 81300, ext: 40700 }, - { m: '8月', own: 18600, ext: 9400 }, - ]; - const months = baseMonths.map((item) => { - let ownKg = Math.round(item.own * yearFactor * ownVerifyFactor); - let extKg = Math.round(item.ext * yearFactor * extVerifyFactor); - if (fleetScope === 'own') extKg = 0; - if (fleetScope === 'external') ownKg = 0; - return { month: item.m, totalKg: ownKg + extKg }; - }); - const toT = (kg: number) => Math.round((kg / 1000) * 100) / 100; - const current = months[months.length - 1]; - const peak = months.reduce((best, cur) => (cur.totalKg > best.totalKg ? cur : best), months[0]); - const avgKg = months.reduce((s, m) => s + m.totalKg, 0) / (months.length || 1); - return `当月 ${toT(current.totalKg)} T · 峰值${peak.month} ${toT(peak.totalKg)} T · 月均 ${toT(avgKg)} T`; - }, [year, fleetScope, verifyScope]); - - useEffect(() => { - if (!stationRankOpen) return; - function onDoc(e: MouseEvent) { - if (stationRankRef.current && !stationRankRef.current.contains(e.target as Node)) { - setStationRankOpen(false); - } - } - document.addEventListener('mousedown', onDoc); - return () => document.removeEventListener('mousedown', onDoc); - }, [stationRankOpen]); - - const dims = useMemo(() => costDimCards(rows), [rows]); - const pending = pendingAmount(rows); - const risk = unverified(rows); - - const companyScoped = useMemo( - () => companyRowsForStats(rows, dimFilter), - [rows, dimFilter], - ); - - /** 客户归属表:无维度筛时看全量归属;有维度筛时只看对应我司成本行 */ - const customerSource = useMemo(() => { - if (!dimFilter) return rows; - return companyScoped; - }, [rows, dimFilter, companyScoped]); - - const siteMonthRows = useMemo(() => stationMonthAgg(companyScoped), [companyScoped]); - const customerRows = useMemo(() => customerAttrAgg(customerSource), [customerSource]); - - const detailRows = useMemo(() => { - let list = companyScoped; - if (stationId) list = list.filter((r) => r.stationId === stationId); - if (customerId) list = list.filter((r) => r.customerId === customerId); - return list; - }, [companyScoped, stationId, customerId]); - - const externalEmpty = fleetScope === 'external' && rows.length === 0; - const [updatedAt, setUpdatedAt] = useState('2026-08-08 10:12'); - - const handleRefreshData = () => { - const now = new Date(); - const timeStr = now.toTimeString().split(' ')[0].slice(0, 5); - setUpdatedAt(`2026-08-08 ${timeStr}`); - }; - - return ( -
- - -
-
-
- 期初校准中:期初余额与成本单价未锁定前,本看板仅供内部核对,不作对外真源。 -
-
- {boardScope === 'global' ? ( - - 📅 {timeRangeLabel}:{timeRangeText} - - ) : null} -
-
-
- - -
- {boardScope === 'global' ? ( -
- - -
- ) : null} -
-
- - {boardScope === 'station' ? ( - - ) : ( - <> - {hostView === 'daily' ? ( - - ) : ( - <> - {/* 总览视角筛选条 (包含年份选择、核对筛选、车辆归属及刷新,样式与按日视角全面对齐) */} -
-
-
- { - setYear(y); - clearEntity(); - }} - /> -
- - -
-
- -
-
- - - -
- - - {updatedAt} - - - -
-
-
- -
-
- } - tone="blue" - label="累计加氢量" - value={hostKpi.totalKgT} - unit="T" - left={`我司 ${hostKpi.companyKgT} T`} - right={`客户 ${hostKpi.customerKgT} T`} - onClick={() => setKpiDrillType('累计加氢量')} - /> - } - tone="blue" - label="累计加氢费" - prefix="¥" - value={hostKpi.totalFeeWan} - unit="万" - left={`我司 ¥${hostKpi.companyFeeWan} 万`} - right={`客户 ¥${hostKpi.customerFeeWan} 万`} - onClick={() => setKpiDrillType('累计加氢费')} - /> - } - tone="green" - label="加氢利润" - prefix="¥" - value={hostKpi.profitWan} - unit="万" - left={`收入 ¥${hostKpi.incomeWan} 万`} - right={`成本 ¥${hostKpi.costWan} 万`} - onClick={() => setKpiDrillType('加氢利润')} - /> - } - tone="amber" - label="本月加氢" - value={hostKpi.monthKgT} - unit="T" - left={`加氢费 ¥${hostKpi.monthFeeWan} 万`} - right={`占年比 ${hostKpi.monthYearPct}%`} - onClick={() => setKpiDrillType('本月加氢')} - /> - } - tone="purple" - label="本日加氢" - value={hostKpi.dayKg} - unit="Kg" - left={`加氢费 ¥${hostKpi.dayFee.toLocaleString('zh-CN')}`} - right={`占月比 ${hostKpi.dayMonthPct}%`} - onClick={() => setKpiDrillType('本日加氢')} - /> -
- -
-
-
- -
-
-
月度加氢异常波动
-
- {hostKpi.monthYearPct > 0 ? `占年 ${hostKpi.monthYearPct}%` : '—'} -
-
- {monthFluctuationDesc} -
-
-
- -
setStationRankOpen((v) => !v)} - style={{ cursor: 'pointer' }} - title="点击查看加氢站加氢量排名" - > -
- -
-
-
头部加氢站占比
-
Top5 {top5SharePct}%
-
- 累计 {hostKpi.totalKgT} T · 点击展开加氢量排名 -
-
- - {stationRankOpen && ( -
e.stopPropagation()} - role="listbox" - aria-label="加氢站加氢量排名" - > -
- 加氢站加氢量排名 - - 高 → 低 · 共 {stationRankList.length} 站 - -
-
- {stationRankList.map((st) => ( - - ))} - {stationRankList.length === 0 && ( -
当前筛选下暂无站点数据
- )} -
-
- )} -
- -
-
- -
-
-
加氢利润率
-
= 0 ? 'is-pos' : 'is-neg'}`}> - {hostKpi.profitRatePct}% -
-
- 加氢利润 {hostKpi.profitWan} 万 · 收入 {hostKpi.incomeWan} 万 -
-
-
-
-
- - {/* 经营趋势图表大盘:月度加氢量、月度收支对比、Top5站加氢量、各区域加氢占比 */} - setKpiDrillType(lbl)} - onOpenCustomerBill={(custName) => setSelectedBillCustomer(custName)} - onOpenStationBill={(stName, prov) => setSelectedStationForDrill({ name: stName, province: prov })} - /> - - )} - - )} - - {/* KPI 点击下钻数据来源穿透 Modal */} - {boardScope === 'global' && kpiDrillType && ( - setKpiDrillType(null)} - /> - )} - - {/* 客户账单专属下钻 Modal (客户 → 日期 → 车牌加氢记录) */} - {boardScope === 'global' && selectedBillCustomer && ( - setSelectedBillCustomer(null)} - /> - )} - - {/* 加氢站账单专属下钻 Modal (加氢站 → 所有日期的加氢量、占比、氢费收入、收入占比) */} - {boardScope === 'global' && selectedStationForDrill && ( - setSelectedStationForDrill(null)} - /> - )} -
-
- ); -}; - -function PlugZapHint() { - return ; -} - -function HostKpi({ - icon, - tone, - label, - value, - prefix, - unit, - left, - right, - onClick, -}: { - icon: React.ReactNode; - tone: 'blue' | 'green' | 'amber' | 'purple' | 'cyan'; - label: string; - value: React.ReactNode; - prefix?: string; - unit?: string; - left: string; - right: string; - onClick?: () => void; -}) { - return ( -
-
- - {label} - - 钻取 - - - {icon} -
-
- {prefix && {prefix}} - {value} - {unit && {unit}} -
-
- {left} - {right} -
-
- ); -} - -/** KPI 数据来源穿透 & 站 -> 客户 -> 车牌三级下钻 Modal 弹窗 */ -interface KpiDrillModalProps { - label: string; - year: number; - fleetScope: FleetScope; - verifyScope: 'all' | 'verified'; - onClose: () => void; -} - -function makeVehicleOrders( - certPrefix: string, - fleetCategory: FleetCategory, - mode: 'verified' | 'unverified' | 'partial', - totalKg: number, -) { - const isOwn = fleetCategory === 'own'; - const unitPrice = 4.5; - const list = [ - { time: '2026-08-08 09:15', factor: 1.2, source: 'api' as const }, - { time: '2026-08-08 14:30', factor: 0.9, source: 'api' as const }, - { time: '2026-08-07 11:20', factor: 1.1, source: 'station_report' as const }, - { time: '2026-08-06 16:45', factor: 0.8, source: 'lingniu_report' as const }, - { time: '2026-08-05 10:10', factor: 1.05, source: 'api' as const }, - { time: '2026-08-04 15:25', factor: 0.95, source: 'station_report' as const }, - { time: '2026-08-03 08:50', factor: 1.15, source: 'api' as const }, - { time: '2026-08-02 17:05', factor: 0.85, source: 'lingniu_report' as const }, - { time: '2026-08-01 12:40', factor: 1.0, source: 'station_report' as const }, - { time: '2026-07-31 09:30', factor: 0.9, source: 'api' as const }, - ]; - - return list.map((item, idx) => { - const seq = String(idx + 1).padStart(2, '0'); - const kg = Math.round(((totalKg / 100) * item.factor) * 10) / 10; - const certNo = - item.source === 'api' - ? `API-20260808-${certPrefix}-${seq}` - : item.source === 'station_report' - ? `ST-20260808-${certPrefix}-${seq}` - : `LN-20260808-${certPrefix}-${seq}`; - - let verifyStatus: 'verified' | 'unverified' | null = null; - if (isOwn) { - if (mode === 'verified') verifyStatus = 'verified'; - else if (mode === 'unverified') verifyStatus = 'unverified'; - else { - verifyStatus = idx % 2 === 0 ? 'verified' : 'unverified'; - } - } - - return { - orderId: `ORD-20260808-${certPrefix}-${seq}`, - time: item.time, - kg, - unitPrice, - amount: Math.round(kg * unitPrice), - source: item.source, - certNo, - verifyStatus, - }; - }); -} - -function computeVehicleVerifyStatus( - orders: { verifyStatus?: 'verified' | 'unverified' | null }[], - fleetCategory: FleetCategory, -): 'verified' | 'unverified' | 'partial' | null { - if (fleetCategory !== 'own') return null; - if (!orders || orders.length === 0) return 'unverified'; - const verifiedCount = orders.filter((o) => o.verifyStatus === 'verified').length; - const unverifiedCount = orders.filter((o) => o.verifyStatus === 'unverified').length; - if (verifiedCount > 0 && unverifiedCount > 0) return 'partial'; - if (verifiedCount > 0 && unverifiedCount === 0) return 'verified'; - return 'unverified'; -} - -/** 站/客户层:按下属车辆核对态汇总。全已核→已核对;全未核→未核对;有混杂或任一带部分→部分核对。外部车不参与。 */ -function aggregateVehiclesVerifyStatus( - vehicles: { orders: { verifyStatus?: 'verified' | 'unverified' | null }[]; fleetCategory: FleetCategory; plateNo?: string }[], -): 'verified' | 'unverified' | 'partial' | null { - const statuses = vehicles - .map((vh) => computeVehicleVerifyStatus(vh.orders, vh.fleetCategory)) - .filter((s): s is 'verified' | 'unverified' | 'partial' => s !== null); - if (statuses.length === 0) return null; - if (statuses.every((s) => s === 'verified')) return 'verified'; - if (statuses.every((s) => s === 'unverified')) return 'unverified'; - return 'partial'; -} - -/** 穿透表标签悬浮说明 */ -const FLEET_TAG_TIP = { - own: '羚牛车辆:车牌可识别,且归属羚牛自有/合作车队', - external: '外部车辆:非羚牛车队;无法识别车牌的归入「无车牌」并标外部车辆', -} as const; - -const SOURCE_TAG_TIP: Record<'api' | 'station_report' | 'lingniu_report', string> = { - api: 'API接入:加氢数据由接口自动归集,可按接口流水追溯', - station_report: '站点上报:由加氢站报送的加氢数据', - lingniu_report: '羚牛上报:从 OneOS 归集的加氢记录', -}; - -const VERIFY_TAG_TIP = { - verified: '已核对:范围内加氢订单均已完成核对', - partial: '部分核对:范围内既有已核对,也有未核对订单', - unverified: '未核对:范围内加氢订单均尚未核对', - order_verified: '已核对:该笔加氢订单已完成核对', - order_unverified: '未核对:该笔加氢订单尚未核对', - external_skip: '外部车辆不参与核对,故无核对状态', -} as const; - -function renderFleetTag(isOwnFleet: boolean) { - return ( - - {isOwnFleet ? '羚牛车辆' : '外部车辆'} - - ); -} - -function renderSourceTag(source: 'api' | 'station_report' | 'lingniu_report' | string) { - const key = source === 'api' || source === 'station_report' || source === 'lingniu_report' ? source : 'api'; - const mod = key === 'api' ? 'api' : key === 'station_report' ? 'station' : 'lingniu'; - return ( - - {SOURCE_TYPE_LABEL[key] || source} - - ); -} - -function renderOrderVerifyTag( - fleetCategory: FleetCategory, - verifyStatus: 'verified' | 'unverified' | null | undefined, -) { - if (fleetCategory !== 'own') { - return ( - - - - - ); - } - if (verifyStatus === 'verified') { - return ( - - 已核对 - - ); - } - return ( - - 未核对 - - ); -} - -function renderVehicleVerifyTag( - fleetCategory: FleetCategory, - status: 'verified' | 'unverified' | 'partial' | null, -) { - if (fleetCategory !== 'own' || status === null) { - return ( - - - - - ); - } - if (status === 'verified') { - return ( - - 已核对 - - ); - } - if (status === 'partial') { - return ( - - 部分核对 - - ); - } - return ( - - 未核对 - - ); -} - -function renderAggVerifyTag( - status: 'verified' | 'unverified' | 'partial' | null, - titlePrefix = '下属车辆', -) { - if (status === 'verified') { - return ( - - 已核对 - - ); - } - if (status === 'partial') { - return ( - - 部分核对 - - ); - } - if (status === 'unverified') { - return ( - - 未核对 - - ); - } - return ( - - - - - ); -} - -function KpiDrillModal({ label, year, fleetScope, verifyScope, onClose }: KpiDrillModalProps) { - const [stationFilter, setStationFilter] = useState('all'); - const [customerFilter, setCustomerFilter] = useState('all'); - const [plateFilter, setPlateFilter] = useState('all'); - const [fleetCategoryFilter, setFleetCategoryFilter] = useState<'all' | 'own' | 'external'>(fleetScope); - - // 跟随顶栏车辆筛选:打开/切换时同步 - useEffect(() => { - setFleetCategoryFilter(fleetScope); - }, [fleetScope, label]); - - // 展开折叠状态 - const [expandedStations, setExpandedStations] = useState>({ - 'st-jx': true, // 默认展开嘉兴站 - }); - const [expandedCustomers, setExpandedCustomers] = useState>({ - 'st-jx_c-zp': true, // 默认展开乍浦港口客户 - }); - const [expandedVehicles, setExpandedVehicles] = useState>({ - 'st-jx_c-zp_浙F88888': true, // 默认展开首辆车,展现单笔订单与部分核对明细 - }); - const [expandedAllVehicleOrders, setExpandedAllVehicleOrders] = useState>({}); - - const toggleStation = (stId: string) => { - setExpandedStations((prev) => ({ ...prev, [stId]: !prev[stId] })); - }; - - const toggleCustomer = (stId: string, custId: string) => { - const key = `${stId}_${custId}`; - setExpandedCustomers((prev) => ({ ...prev, [key]: !prev[key] })); - }; - - const toggleVehicle = (stId: string, custId: string, plateNo: string) => { - const key = `${stId}_${custId}_${plateNo}`; - setExpandedVehicles((prev) => ({ ...prev, [key]: !prev[key] })); - }; - - const toggleShowAllOrders = (vhKey: string) => { - setExpandedAllVehicleOrders((prev) => ({ ...prev, [vhKey]: !prev[vhKey] })); - }; - - // 站 -> 客户 -> 车牌 & 接口凭证 高保真全链路数据 - const drillStations = useMemo(() => { - const is2026 = year === 2026; - const factor = is2026 ? 1 : 0.85; - - return [ - { - stationId: 'st-jx', - stationName: '嘉兴中石化滨海加氢站', - stationType: 'self_use' as const, - province: '浙江省', - totalKg: Math.round(243661 * factor), - totalFeeWan: (1096.47 * factor).toFixed(2), - customers: [ - { - customerId: 'c-zp', - customerName: '嘉兴市乍浦港口经营有限公司', - category: 'internal' as const, - totalKg: Math.round(163250 * factor), - totalFeeWan: (734.63 * factor).toFixed(2), - vehicles: [ - { plateNo: '浙F88888', fleetCategory: 'own' as const, source: 'api' as const, certNo: 'API-20260808-9821', count: 142, kg: 42600, amount: 191700, orders: makeVehicleOrders('9821', 'own', 'partial', 42600) }, - { plateNo: '浙F66666', fleetCategory: 'own' as const, source: 'api' as const, certNo: 'API-20260808-9822', count: 128, kg: 38400, amount: 172800, orders: makeVehicleOrders('9822', 'own', 'verified', 38400) }, - { plateNo: '浙F77777', fleetCategory: 'own' as const, source: 'lingniu_report' as const, certNo: 'LN-REP-202608-012', count: 110, kg: 33000, amount: 148500, orders: makeVehicleOrders('012', 'own', 'verified', 33000) }, - { plateNo: '浙F55555', fleetCategory: 'own' as const, source: 'station_report' as const, certNo: 'ST-REP-202608-045', count: 98, kg: 29400, amount: 132300, orders: makeVehicleOrders('045', 'own', 'unverified', 29400) }, - { plateNo: '浙F33333', fleetCategory: 'own' as const, source: 'api' as const, certNo: 'API-20260808-9825', count: 66, kg: 19850, amount: 89325, orders: makeVehicleOrders('9825', 'own', 'partial', 19850) }, - ], - }, - { - customerId: 'c-ys', - customerName: '嘉兴益顺冷链物流有限公司', - category: 'external' as const, - totalKg: Math.round(54869 * factor), - totalFeeWan: (246.91 * factor).toFixed(2), - vehicles: [ - { plateNo: '浙F12345', fleetCategory: 'external' as const, source: 'api' as const, certNo: 'API-20260808-7712', count: 85, kg: 25500, amount: 114750, orders: makeVehicleOrders('7712', 'external', 'verified', 25500) }, - { plateNo: '浙F67890', fleetCategory: 'external' as const, source: 'station_report' as const, certNo: 'ST-REP-202608-088', count: 62, kg: 18600, amount: 83700, orders: makeVehicleOrders('088', 'external', 'verified', 18600) }, - { plateNo: '无车牌', fleetCategory: 'external' as const, source: 'station_report' as const, certNo: 'ST-REP-202608-099', count: 36, kg: 10769, amount: 48460, orders: makeVehicleOrders('099', 'external', 'verified', 10769) }, - ], - }, - { - customerId: 'c-zq', - customerName: '嘉兴智奇供应链管理有限公司', - category: 'external' as const, - totalKg: Math.round(25542 * factor), - totalFeeWan: (114.93 * factor).toFixed(2), - vehicles: [ - { plateNo: '沪A99881', fleetCategory: 'external' as const, source: 'api' as const, certNo: 'API-20260808-6623', count: 52, kg: 15600, amount: 70200, orders: makeVehicleOrders('6623', 'external', 'verified', 15600) }, - { plateNo: '沪A99882', fleetCategory: 'external' as const, source: 'api' as const, certNo: 'API-20260808-6624', count: 33, kg: 9942, amount: 44739, orders: makeVehicleOrders('6624', 'external', 'verified', 9942) }, - ], - }, - ], - }, - { - stationId: 'st-jj', - stationName: '嘉兴嘉锦加氢站', - stationType: 'external_sale' as const, - province: '浙江省', - totalKg: Math.round(182889 * factor), - totalFeeWan: (823.00 * factor).toFixed(2), - customers: [ - { - customerId: 'c-ln', - customerName: '羚牛自营车队', - category: 'internal' as const, - totalKg: Math.round(128020 * factor), - totalFeeWan: (576.09 * factor).toFixed(2), - vehicles: [ - { plateNo: '浙A88881F', fleetCategory: 'own' as const, source: 'api' as const, certNo: 'API-20260808-1001', count: 180, kg: 54000, amount: 243000, orders: makeVehicleOrders('1001', 'own', 'verified', 54000) }, - { plateNo: '浙A88882F', fleetCategory: 'own' as const, source: 'lingniu_report' as const, certNo: 'LN-REP-202608-102', count: 150, kg: 45000, amount: 202500, orders: makeVehicleOrders('102', 'own', 'verified', 45000) }, - { plateNo: '浙A88883F', fleetCategory: 'own' as const, source: 'station_report' as const, certNo: 'ST-REP-202608-103', count: 96, kg: 29020, amount: 130590, orders: makeVehicleOrders('103', 'own', 'unverified', 29020) }, - ], - }, - { - customerId: 'c-qb', - customerName: '四川群彬物流有限公司', - category: 'external' as const, - totalKg: Math.round(54869 * factor), - totalFeeWan: (246.91 * factor).toFixed(2), - vehicles: [ - { plateNo: '川A77123', fleetCategory: 'external' as const, source: 'api' as const, certNo: 'API-20260808-3301', count: 110, kg: 33000, amount: 148500, orders: makeVehicleOrders('3301', 'external', 'verified', 33000) }, - { plateNo: '川A77124', fleetCategory: 'external' as const, source: 'station_report' as const, certNo: 'ST-REP-202608-332', count: 72, kg: 21869, amount: 98410, orders: makeVehicleOrders('332', 'external', 'verified', 21869) }, - ], - }, - ], - }, - { - stationId: 'st-jr', - stationName: '嘉兴嘉燃加氢站', - stationType: 'self_use' as const, - province: '浙江省', - totalKg: Math.round(28234 * factor), - totalFeeWan: (127.05 * factor).toFixed(2), - customers: [ - { - customerId: 'c-yj', - customerName: '浙江洋井供应链管理有限公司', - category: 'external' as const, - totalKg: Math.round(28234 * factor), - totalFeeWan: (127.05 * factor).toFixed(2), - vehicles: [ - { plateNo: '浙B99112', fleetCategory: 'external' as const, source: 'api' as const, certNo: 'API-20260808-4411', count: 60, kg: 18350, amount: 82575, orders: makeVehicleOrders('4411', 'external', 'verified', 18350) }, - { plateNo: '浙B99113', fleetCategory: 'external' as const, source: 'api' as const, certNo: 'API-20260808-4412', count: 32, kg: 9884, amount: 44478, orders: makeVehicleOrders('4412', 'external', 'verified', 9884) }, - ], - }, - ], - }, - { - stationId: 'st-ln', - stationName: '桐乡中石化绿能加氢站', - stationType: 'external_sale' as const, - province: '浙江省', - totalKg: Math.round(26080 * factor), - totalFeeWan: (117.36 * factor).toFixed(2), - customers: [ - { - customerId: 'c-js', - customerName: '重庆金时源供应链有限公司', - category: 'external' as const, - totalKg: Math.round(26080 * factor), - totalFeeWan: (117.36 * factor).toFixed(2), - vehicles: [ - { plateNo: '渝A66881', fleetCategory: 'external' as const, source: 'api' as const, certNo: 'API-20260808-5501', count: 52, kg: 15648, amount: 70416, orders: makeVehicleOrders('5501', 'external', 'verified', 15648) }, - { plateNo: '渝A66882', fleetCategory: 'external' as const, source: 'station_report' as const, certNo: 'ST-REP-202608-552', count: 35, kg: 10432, amount: 46944, orders: makeVehicleOrders('552', 'external', 'verified', 10432) }, - ], - }, - ], - }, - { - stationId: 'st-tf', - stationName: '成都中石化天府机场北站', - stationType: 'external_sale' as const, - province: '四川省', - totalKg: Math.round(22929 * factor), - totalFeeWan: (103.18 * factor).toFixed(2), - customers: [ - { - customerId: 'c-gz', - customerName: '四川拱照物流有限公司', - category: 'external' as const, - totalKg: Math.round(22929 * factor), - totalFeeWan: (103.18 * factor).toFixed(2), - vehicles: [ - { plateNo: '川A88901', fleetCategory: 'external' as const, source: 'api' as const, certNo: 'API-20260808-8801', count: 53, kg: 16050, amount: 72225, orders: makeVehicleOrders('8801', 'external', 'verified', 16050) }, - { plateNo: '川A88902', fleetCategory: 'external' as const, source: 'station_report' as const, certNo: 'ST-REP-202608-8802', count: 23, kg: 6879, amount: 30955, orders: makeVehicleOrders('8802', 'external', 'verified', 6879) }, - ], - }, - ], - }, - ]; - }, [year]); - - // 按条件过滤(站/客户/车牌可搜索选择 + 车辆归属 + 顶栏核对范围) - const filteredStations = useMemo(() => { - return drillStations - .map((st) => { - if (stationFilter !== 'all' && st.stationId !== stationFilter) return null; - - const filteredCustomers = st.customers - .map((cust) => { - if (customerFilter !== 'all' && cust.customerId !== customerFilter) return null; - - const filteredVehicles = cust.vehicles - .map((vh) => { - if (fleetCategoryFilter !== 'all' && vh.fleetCategory !== fleetCategoryFilter) return null; - if (plateFilter !== 'all' && vh.plateNo !== plateFilter) return null; - - if (verifyScope === 'verified') { - // 仅已核对:外部车不参与核对;羚牛车只保留已核订单 - if (vh.fleetCategory !== 'own') return null; - const orders = vh.orders.filter((o) => o.verifyStatus === 'verified'); - if (orders.length === 0) return null; - const kg = orders.reduce((s, o) => s + o.kg, 0); - const amount = orders.reduce((s, o) => s + o.amount, 0); - return { ...vh, orders, count: orders.length, kg, amount }; - } - return vh; - }) - .filter(Boolean) as typeof cust.vehicles; - - if (filteredVehicles.length === 0) return null; - return { - ...cust, - vehicles: filteredVehicles, - totalKg: filteredVehicles.reduce((sum, v) => sum + v.kg, 0), - totalFeeWan: (filteredVehicles.reduce((sum, v) => sum + v.amount, 0) / 10000).toFixed(2), - }; - }) - .filter(Boolean) as typeof st.customers; - - if (filteredCustomers.length === 0) return null; - - return { - ...st, - customers: filteredCustomers, - totalKg: filteredCustomers.reduce((sum, c) => sum + c.totalKg, 0), - totalFeeWan: (filteredCustomers.reduce((sum, c) => sum + parseFloat(c.totalFeeWan), 0)).toFixed(2), - }; - }) - .filter(Boolean) as typeof drillStations; - }, [drillStations, stationFilter, customerFilter, plateFilter, fleetCategoryFilter, verifyScope]); - - // 级联选项:站 → 客户 → 车牌(选项池随上级选择收窄) - const stationOptions = useMemo( - () => drillStations.map((st) => ({ value: st.stationId, label: st.stationName })), - [drillStations], - ); - - const customerOptions = useMemo(() => { - const map = new Map(); - drillStations.forEach((st) => { - if (stationFilter !== 'all' && st.stationId !== stationFilter) return; - st.customers.forEach((c) => { - if (!map.has(c.customerId)) map.set(c.customerId, c.customerName); - }); - }); - return Array.from(map.entries()).map(([value, label]) => ({ value, label })); - }, [drillStations, stationFilter]); - - const plateOptions = useMemo(() => { - const set = new Set(); - drillStations.forEach((st) => { - if (stationFilter !== 'all' && st.stationId !== stationFilter) return; - st.customers.forEach((c) => { - if (customerFilter !== 'all' && c.customerId !== customerFilter) return; - c.vehicles.forEach((vh) => { - if (fleetCategoryFilter !== 'all' && vh.fleetCategory !== fleetCategoryFilter) return; - set.add(vh.plateNo); - }); - }); - }); - return Array.from(set).map((plate) => ({ - value: plate, - label: /无车牌/.test(plate) ? '无车牌' : plate, - })); - }, [drillStations, stationFilter, customerFilter, fleetCategoryFilter]); - - // 上级变更时清掉下级无效选中 - useEffect(() => { - if (customerFilter !== 'all' && !customerOptions.some((o) => o.value === customerFilter)) { - setCustomerFilter('all'); - setPlateFilter('all'); - } - }, [customerOptions, customerFilter]); - - useEffect(() => { - if (plateFilter !== 'all' && !plateOptions.some((o) => o.value === plateFilter)) { - setPlateFilter('all'); - } - }, [plateOptions, plateFilter]); - - // KPI 穿透列口径:默认量/金额;加氢利润→收入/成本/利润;本月→月量/月费/占年比;本日→日量/日费/占月比;月度柱→站×内外部客户量;收支柱→站×收入/成本 - const isProfitDrill = label === '加氢利润'; - const isMonthDrill = label === '本月加氢'; - const isDayDrill = label === '本日加氢'; - const monthMetricMatch = label.match(/^(\d{4})年(\d{1,2})月(加氢量|客户收入|成本支出)$/); - const isMonthBarDrill = monthMetricMatch?.[3] === '加氢量'; - const isMonthIncomeDrill = monthMetricMatch?.[3] === '客户收入'; - const isMonthCostDrill = monthMetricMatch?.[3] === '成本支出'; - const isStationMonthFlat = isMonthBarDrill || isMonthIncomeDrill || isMonthCostDrill; - const stationCustMatch = label.match(/^加氢站(?:客户量)?:(.+)$/); - const isStationCustomerDrill = Boolean(stationCustMatch); - const stationCustTarget = stationCustMatch?.[1]?.trim() ?? ''; - const regionMatch = label.match(/^区域(市|省):(.+)$/); - const isRegionDrill = Boolean(regionMatch); - const regionKind = regionMatch?.[1] as '市' | '省' | undefined; - const regionLabel = regionMatch?.[2]?.trim() ?? ''; - const isFlatOverviewDrill = isStationMonthFlat || isStationCustomerDrill || isRegionDrill; - const monthBarIndex = monthMetricMatch ? Number(monthMetricMatch[2]) - 1 : -1; - const MONTH_BAR_KG = [85200, 52000, 112800, 135000, 128000, 118000, 122000, 28000]; - const monthBarYearKg = MONTH_BAR_KG.reduce((s, n) => s + n, 0) || 1; - const monthBarShare = - monthBarIndex >= 0 && monthBarIndex < MONTH_BAR_KG.length - ? MONTH_BAR_KG[monthBarIndex] / monthBarYearKg - : 1; - const incomeRatio = HOST_KPI.incomeWan / (HOST_KPI.costWan || 1); - const profitRatio = HOST_KPI.profitWan / (HOST_KPI.costWan || 1); - const monthShare = HOST_KPI.monthKgT / (HOST_KPI.totalKgT || 1); - const dayShare = HOST_KPI.dayKg / ((HOST_KPI.totalKgT || 1) * 1000); - const toIncomeYuan = (costYuan: number) => Math.round(costYuan * incomeRatio * 100) / 100; - const toProfitYuan = (costYuan: number) => Math.round(costYuan * profitRatio * 100) / 100; - const toMonthKg = (kg: number) => Math.round(kg * monthShare * 100) / 100; - const toMonthFee = (yuan: number) => Math.round(yuan * monthShare * 100) / 100; - const toDayKg = (kg: number) => Math.round(kg * dayShare * 100) / 100; - const toDayFee = (yuan: number) => Math.round(yuan * dayShare * 100) / 100; - const feeWanToYuan = (wan: string | number) => parseFloat(String(wan)) * 10000; - const filteredYearFeeYuan = filteredStations.reduce((s, st) => s + feeWanToYuan(st.totalFeeWan), 0); - const filteredMonthFeeYuan = toMonthFee(filteredYearFeeYuan); - const toFeeYearPct = (monthFeeYuan: number) => - filteredYearFeeYuan > 0 ? Math.round((monthFeeYuan / filteredYearFeeYuan) * 10000) / 100 : 0; - const toFeeMonthPct = (dayFeeYuan: number) => - filteredMonthFeeYuan > 0 ? Math.round((dayFeeYuan / filteredMonthFeeYuan) * 10000) / 100 : 0; - const colCount = isProfitDrill || isMonthDrill || isDayDrill ? 8 : 7; - - /** 月度柱钻取:各站内部/外部客户加氢量 + 合计(按当月占年份额缩放) */ - const stationMonthRows = useMemo(() => { - return filteredStations - .map((st) => { - const internalKg = st.customers - .filter((c) => c.category === 'internal') - .reduce((s, c) => s + c.totalKg, 0); - const externalKg = st.customers - .filter((c) => c.category === 'external') - .reduce((s, c) => s + c.totalKg, 0); - return { - stationId: st.stationId, - stationName: st.stationName, - province: st.province, - internalKg: Math.round(internalKg * monthBarShare), - externalKg: Math.round(externalKg * monthBarShare), - totalKg: Math.round((internalKg + externalKg) * monthBarShare), - }; - }) - .sort((a, b) => b.totalKg - a.totalKg); - }, [filteredStations, monthBarShare]); - - /** 月度收支柱钻取:各站客户收入 / 成本支出 */ - const stationRevRows = useMemo(() => { - return filteredStations - .map((st) => { - const monthFee = feeWanToYuan(st.totalFeeWan) * monthBarShare; - return { - stationId: st.stationId, - stationName: st.stationName, - province: st.province, - incomeYuan: Math.round(monthFee * incomeRatio), - costYuan: Math.round(monthFee), - }; - }) - .sort((a, b) => - isMonthCostDrill ? b.costYuan - a.costYuan : b.incomeYuan - a.incomeYuan, - ); - }, [filteredStations, monthBarShare, incomeRatio, isMonthCostDrill]); - - const monthBarInternalSum = stationMonthRows.reduce((s, r) => s + r.internalKg, 0); - const monthBarExternalSum = stationMonthRows.reduce((s, r) => s + r.externalKg, 0); - const monthBarTotalSum = stationMonthRows.reduce((s, r) => s + r.totalKg, 0); - const monthIncomeSum = stationRevRows.reduce((s, r) => s + r.incomeYuan, 0); - const monthCostSum = stationRevRows.reduce((s, r) => s + r.costYuan, 0); - - /** Top5 / 站名钻取:该站内部客户 vs 外部客户加氢总量 */ - const stationCustomerRows = useMemo(() => { - if (!isStationCustomerDrill || !stationCustTarget) return []; - const st = - drillStations.find((s) => s.stationName === stationCustTarget) || - drillStations.find( - (s) => - s.stationName.includes(stationCustTarget.replace(/加氢站$/, '')) || - stationCustTarget.includes(s.stationName.replace(/加氢站$/, '')), - ); - if (!st) { - const top = [ - { rank: 1, name: '嘉兴中石化滨海加氢站', ownKg: 163250, extKg: 80411 }, - { rank: 2, name: '嘉兴嘉锦加氢站', ownKg: 128020, extKg: 54869 }, - { rank: 3, name: '嘉兴嘉燃加氢站', ownKg: 18350, extKg: 9884 }, - { rank: 4, name: '桐乡中石化绿能加氢站', ownKg: 15648, extKg: 10432 }, - { rank: 5, name: '成都中石化天府机场北站', ownKg: 16050, extKg: 6879 }, - ].find((t) => t.name === stationCustTarget || stationCustTarget.includes(t.name.slice(0, 6))); - if (!top) return []; - return [ - { customerId: 'own', customerName: '内部客户合计', category: 'internal' as const, totalKg: top.ownKg }, - { customerId: 'ext', customerName: '外部客户合计', category: 'external' as const, totalKg: top.extKg }, - ]; - } - return st.customers - .map((c) => ({ - customerId: c.customerId, - customerName: c.customerName, - category: c.category, - totalKg: c.totalKg, - })) - .sort((a, b) => b.totalKg - a.totalKg); - }, [drillStations, isStationCustomerDrill, stationCustTarget]); - - const stationCustInternalKg = stationCustomerRows - .filter((r) => r.category === 'internal') - .reduce((s, r) => s + r.totalKg, 0); - const stationCustExternalKg = stationCustomerRows - .filter((r) => r.category === 'external') - .reduce((s, r) => s + r.totalKg, 0); - const stationCustTotalKg = stationCustInternalKg + stationCustExternalKg; - - /** 区域(市/省)钻取:区域内各站加氢总量与占比 */ - const regionStationRows = useMemo(() => { - if (!isRegionDrill || !regionLabel) return []; - const CITY_KEYS: Record = { - 嘉兴市: ['嘉兴', '桐乡', '嘉善'], - 成都市: ['成都'], - 佛山市: ['佛山'], - 昆山市: ['昆山'], - 常熟市: ['常熟'], - 广州市: ['广州'], - 深圳市: ['深圳'], - 无锡市: ['无锡'], - }; - const matchCity = (name: string, city: string) => { - if (city === '其他城市') { - const keys = Object.values(CITY_KEYS).flat(); - return !keys.some((k) => name.includes(k)); - } - const keys = CITY_KEYS[city] || [city.replace(/市$/, '')]; - return keys.some((k) => name.includes(k)); - }; - const list = MOCK_STATION_SUMMARY_LIST.filter((st) => { - if (regionKind === '省') { - if (regionLabel === '其他省份') { - return !['浙江省', '四川省', '广东省', '江苏省'].includes(st.province); - } - return st.province === regionLabel; - } - return matchCity(st.name, regionLabel); - }); - const totalKg = list.reduce((s, st) => s + parseFloat(st.kgT) * 1000, 0) || 1; - return list - .map((st) => { - const kg = Math.round(parseFloat(st.kgT) * 1000); - return { - name: st.name, - province: st.province, - kg, - kgT: st.kgT, - pct: Math.round((kg / totalKg) * 10000) / 100, - incomeWan: st.incomeWan, - }; - }) - .sort((a, b) => b.kg - a.kg); - }, [isRegionDrill, regionKind, regionLabel]); - - const regionKgSum = regionStationRows.reduce((s, r) => s + r.kg, 0); - - const renderMetricCells = (kg: number, feeYuan: number) => { - if (isProfitDrill) { - return ( - <> - - ¥{toIncomeYuan(feeYuan).toLocaleString('zh-CN')} - - - ¥{feeYuan.toLocaleString('zh-CN')} - - - ¥{toProfitYuan(feeYuan).toLocaleString('zh-CN')} - - - ); - } - if (isMonthDrill) { - const mKg = toMonthKg(kg); - const mFee = toMonthFee(feeYuan); - return ( - <> - - {mKg.toLocaleString('zh-CN')} Kg - - - ¥{mFee.toLocaleString('zh-CN')} - - - {toFeeYearPct(mFee).toFixed(2)}% - - - ); - } - if (isDayDrill) { - const dKg = toDayKg(kg); - const dFee = toDayFee(feeYuan); - return ( - <> - - {dKg.toLocaleString('zh-CN')} Kg - - - ¥{dFee.toLocaleString('zh-CN')} - - - {toFeeMonthPct(dFee).toFixed(2)}% - - - ); - } - return ( - <> - - {kg.toLocaleString('zh-CN')} Kg - - - ¥{feeYuan.toLocaleString('zh-CN')} - - - ); - }; - - const handleExportDrillExcel = () => { - if (isMonthBarDrill) { - const aoa: (string | number)[][] = [ - ['加氢站名称', '所属省份', '内部客户加氢总量(Kg)', '外部客户加氢总量(Kg)', '合计加氢总量(Kg)'], - ]; - stationMonthRows.forEach((r) => { - aoa.push([r.stationName, r.province, r.internalKg, r.externalKg, r.totalKg]); - }); - downloadExcelAoa(aoa, `${label}_各站内外部客户加氢量.xlsx`, '月度各站加氢量'); - return; - } - if (isMonthIncomeDrill) { - const aoa: (string | number)[][] = [['加氢站名称', '所属省份', '客户收入(元)']]; - stationRevRows.forEach((r) => { - aoa.push([r.stationName, r.province, r.incomeYuan]); - }); - downloadExcelAoa(aoa, `${label}_各站客户收入.xlsx`, '月度各站客户收入'); - return; - } - if (isMonthCostDrill) { - const aoa: (string | number)[][] = [['加氢站名称', '所属省份', '成本支出(元)']]; - stationRevRows.forEach((r) => { - aoa.push([r.stationName, r.province, r.costYuan]); - }); - downloadExcelAoa(aoa, `${label}_各站成本支出.xlsx`, '月度各站成本支出'); - return; - } - if (isStationCustomerDrill) { - const aoa: (string | number)[][] = [ - ['加氢站', '客户名称', '客户类型', '加氢总量(Kg)'], - ]; - stationCustomerRows.forEach((r) => { - aoa.push([ - stationCustTarget, - r.customerName, - r.category === 'internal' ? '内部客户' : '外部客户', - r.totalKg, - ]); - }); - downloadExcelAoa(aoa, `${stationCustTarget}_内外部客户加氢量.xlsx`, '站客户加氢量'); - return; - } - if (isRegionDrill) { - const aoa: (string | number)[][] = [ - ['区域', '加氢站名称', '所属省份', '加氢总量(Kg)', '区域内占比(%)'], - ]; - regionStationRows.forEach((r) => { - aoa.push([regionLabel, r.name, r.province, r.kg, r.pct]); - }); - downloadExcelAoa(aoa, `${regionLabel}_各站加氢总量占比.xlsx`, '区域各站加氢量'); - return; - } - - const aoa: (string | number)[][] = [ - isProfitDrill - ? [ - '加氢站名称', - '客户名称', - '车牌号', - '车辆归属', - '订单编号', - '加氢时间', - '数据来源', - '订单核对状态', - '加氢量(Kg)', - '收入(元)', - '成本(元)', - '利润(元)', - ] - : isMonthDrill - ? [ - '加氢站名称', - '客户名称', - '车牌号', - '车辆归属', - '订单编号', - '加氢时间', - '数据来源', - '订单核对状态', - '本月加氢量(Kg)', - '本月加氢费(元)', - '加氢费占年比(%)', - ] - : isDayDrill - ? [ - '加氢站名称', - '客户名称', - '车牌号', - '车辆归属', - '订单编号', - '加氢时间', - '数据来源', - '订单核对状态', - '本日加氢量(Kg)', - '本日加氢费(元)', - '加氢费占月比(%)', - ] - : [ - '加氢站名称', - '加氢站类型', - '客户名称', - '客户类型', - '车牌号', - '车辆归属', - '订单编号', - '加氢时间', - '数据来源', - '来源凭证/接口流水号', - '订单核对状态', - '单价(元/Kg)', - '加氢量(Kg)', - '加氢金额(元)', - ], - ]; - - drillStations.forEach((st) => { - st.customers.forEach((cust) => { - cust.vehicles.forEach((vh) => { - vh.orders.forEach((ord) => { - if (isProfitDrill) { - aoa.push([ - st.stationName, - cust.customerName, - vh.plateNo, - vh.fleetCategory === 'own' ? '羚牛车辆' : '外部车辆', - ord.orderId, - ord.time, - SOURCE_TYPE_LABEL[ord.source] || ord.source, - vh.fleetCategory === 'own' ? (DAILY_VERIFY_LABEL[ord.verifyStatus || 'unverified'] || '未核对') : '-', - ord.kg, - toIncomeYuan(ord.amount), - ord.amount, - toProfitYuan(ord.amount), - ]); - } else if (isMonthDrill) { - const mFee = toMonthFee(ord.amount); - aoa.push([ - st.stationName, - cust.customerName, - vh.plateNo, - vh.fleetCategory === 'own' ? '羚牛车辆' : '外部车辆', - ord.orderId, - ord.time, - SOURCE_TYPE_LABEL[ord.source] || ord.source, - vh.fleetCategory === 'own' ? (DAILY_VERIFY_LABEL[ord.verifyStatus || 'unverified'] || '未核对') : '-', - toMonthKg(ord.kg), - mFee, - toFeeYearPct(mFee), - ]); - } else if (isDayDrill) { - const dFee = toDayFee(ord.amount); - aoa.push([ - st.stationName, - cust.customerName, - vh.plateNo, - vh.fleetCategory === 'own' ? '羚牛车辆' : '外部车辆', - ord.orderId, - ord.time, - SOURCE_TYPE_LABEL[ord.source] || ord.source, - vh.fleetCategory === 'own' ? (DAILY_VERIFY_LABEL[ord.verifyStatus || 'unverified'] || '未核对') : '-', - toDayKg(ord.kg), - dFee, - toFeeMonthPct(dFee), - ]); - } else { - aoa.push([ - st.stationName, - st.stationType === 'self_use' ? '自用消费' : '对外销售', - cust.customerName, - cust.category === 'internal' ? '内部客户' : '外部客户', - vh.plateNo, - vh.fleetCategory === 'own' ? '羚牛车辆' : '外部车辆', - ord.orderId, - ord.time, - SOURCE_TYPE_LABEL[ord.source] || ord.source, - ord.certNo, - vh.fleetCategory === 'own' ? (DAILY_VERIFY_LABEL[ord.verifyStatus || 'unverified'] || '未核对') : '-', - ord.unitPrice, - ord.kg, - ord.amount, - ]); - } - }); - }); - }); - }); - - const fileName = isProfitDrill - ? `${year}年_加氢利润_收入成本穿透明细.xlsx` - : isMonthDrill - ? `${year}年_本月加氢_量费占年比穿透明细.xlsx` - : isDayDrill - ? `${year}年_本日加氢_量费占月比穿透明细.xlsx` - : `${year}年_${label}_加氢站_客户_车牌_单笔订单穿透流水账单.xlsx`; - const sheetName = isProfitDrill - ? '加氢利润收入成本明细' - : isMonthDrill - ? '本月加氢穿透明细' - : isDayDrill - ? '本日加氢穿透明细' - : 'KPI穿透订单明细'; - downloadExcelAoa(aoa, fileName, sheetName); - }; - - return ( -
-
e.stopPropagation()}> - {/* Modal 头部 */} -
-
- -
-
- 「{year}」{label}明细 -
-
-
- -
- -
-
- - {/* Modal 内容区 */} -
- {isFlatOverviewDrill ? ( - isStationCustomerDrill ? ( - <> -
-
- 加氢站 - - {stationCustTarget} - -
-
- 内部客户加氢总量 - - {stationCustInternalKg.toLocaleString('zh-CN')} Kg - -
-
- 外部客户加氢总量 - - {stationCustExternalKg.toLocaleString('zh-CN')} Kg - -
-
- 合计加氢总量 - - {stationCustTotalKg.toLocaleString('zh-CN')} Kg - -
-
-
-
- -
该站内部客户与外部客户加氢总量(可纵向滚动)
-
-
-
- - - - - - - - - - - {stationCustomerRows.length === 0 ? ( - - - - ) : ( - stationCustomerRows.map((row) => ( - - - - - - - )) - )} - -
客户类型加氢总量 (Kg)站内占比
- 暂无该站客户数据 -
{row.customerName} - - {row.category === 'internal' ? '内部客户' : '外部客户'} - - - {row.totalKg.toLocaleString('zh-CN')} - - {stationCustTotalKg > 0 - ? ((row.totalKg / stationCustTotalKg) * 100).toFixed(1) - : '0.0'} - % -
-
- - ) : isRegionDrill ? ( - <> -
-
- 区域 - - {regionLabel} - -
-
- 加氢总量 - - {(regionKgSum / 1000).toFixed(2)} T - -
-
- 覆盖加氢站数 - {regionStationRows.length} 站 -
-
-
-
- -
- 该{regionKind}各加氢站加氢总量与区域内占比(可纵向滚动) -
-
-
-
- - - - - - - - - - - - {regionStationRows.length === 0 ? ( - - - - ) : ( - regionStationRows.map((row, idx) => ( - - - - - - - - )) - )} - -
#加氢站所属省份加氢总量 (Kg)区域内占比
- 该区域暂无加氢站数据 -
{idx + 1}{row.name}{row.province} - {row.kg.toLocaleString('zh-CN')} - - {row.pct.toFixed(1)}% -
-
- - ) : ( - <> -
- {isMonthBarDrill ? ( - <> -
- 内部客户加氢总量 - - {monthBarInternalSum.toLocaleString('zh-CN')} Kg - -
-
- 外部客户加氢总量 - - {monthBarExternalSum.toLocaleString('zh-CN')} Kg - -
-
- 合计加氢总量 - - {monthBarTotalSum.toLocaleString('zh-CN')} Kg - -
- - ) : isMonthIncomeDrill ? ( - <> -
- 客户收入合计 - - ¥{monthIncomeSum.toLocaleString('zh-CN')} - -
-
- 站均收入 - - ¥ - {(stationRevRows.length - ? Math.round(monthIncomeSum / stationRevRows.length) - : 0 - ).toLocaleString('zh-CN')} - -
- - ) : ( - <> -
- 成本支出合计 - - ¥{monthCostSum.toLocaleString('zh-CN')} - -
-
- 站均成本 - - ¥ - {(stationRevRows.length - ? Math.round(monthCostSum / stationRevRows.length) - : 0 - ).toLocaleString('zh-CN')} - -
- - )} -
- 覆盖加氢站数 - - {(isMonthBarDrill ? stationMonthRows : stationRevRows).length} 站 - -
-
- -
-
- { - setStationFilter(v); - setCustomerFilter('all'); - setPlateFilter('all'); - }} - options={stationOptions} - allLabel="全部加氢站" - placeholder="搜索加氢站…" - width={220} - /> -
- - - -
- -
- {isMonthBarDrill - ? '该月各加氢站:内部客户加氢总量 · 外部客户加氢总量 · 合计' - : isMonthIncomeDrill - ? '该月各加氢站客户收入' - : '该月各加氢站成本支出'} -
-
-
- -
- - - - - - {isMonthBarDrill ? ( - <> - - - - - ) : ( - - )} - - - - {isMonthBarDrill ? ( - stationMonthRows.length === 0 ? ( - - - - ) : ( - stationMonthRows.map((row) => ( - - - - - - - - )) - ) - ) : stationRevRows.length === 0 ? ( - - - - ) : ( - stationRevRows.map((row) => ( - - - - - - )) - )} - -
加氢站所属省份内部客户加氢总量 (Kg)外部客户加氢总量 (Kg)合计加氢总量 (Kg) - {isMonthIncomeDrill ? '客户收入 (元)' : '成本支出 (元)'} -
- 暂无符合筛选的加氢站数据 -
{row.stationName}{row.province} - {row.internalKg.toLocaleString('zh-CN')} - - {row.externalKg.toLocaleString('zh-CN')} - - {row.totalKg.toLocaleString('zh-CN')} -
- 暂无符合筛选的加氢站数据 -
{row.stationName}{row.province} - ¥{(isMonthIncomeDrill ? row.incomeYuan : row.costYuan).toLocaleString('zh-CN')} -
-
- - ) - ) : ( - <> - {/* 汇总与追溯证明 Card */} -
- {isProfitDrill ? ( - <> -
- 收入合计 - - ¥{toIncomeYuan( - filteredStations.reduce((s, st) => s + feeWanToYuan(st.totalFeeWan), 0), - ).toLocaleString('zh-CN')} - -
-
- 成本合计 - - ¥{filteredStations - .reduce((s, st) => s + feeWanToYuan(st.totalFeeWan), 0) - .toLocaleString('zh-CN')} - -
-
- 加氢利润 - - ¥{(HOST_KPI.profitWan * 10000).toLocaleString('zh-CN')} - -
-
- 覆盖加氢站数 - {filteredStations.length} 站 -
- - ) : isMonthDrill ? ( - <> -
- 本月加氢量 - - {(toMonthKg(filteredStations.reduce((s, st) => s + st.totalKg, 0)) / 1000).toFixed(2)} T - -
-
- 本月加氢费 - - ¥{(toMonthFee(filteredYearFeeYuan) / 10000).toFixed(2)} 万元 - -
-
- 加氢费占年比 - - {(monthShare * 100).toFixed(2)}% - -
-
- 覆盖加氢站数 - {filteredStations.length} 站 -
- - ) : isDayDrill ? ( - <> -
- 本日加氢量 - - {toDayKg(filteredStations.reduce((s, st) => s + st.totalKg, 0)).toLocaleString('zh-CN')} Kg - -
-
- 本日加氢费 - - ¥{toDayFee(filteredYearFeeYuan).toLocaleString('zh-CN')} - -
-
- 加氢费占月比 - - {filteredMonthFeeYuan > 0 - ? ((toDayFee(filteredYearFeeYuan) / filteredMonthFeeYuan) * 100).toFixed(2) - : '0.00'} - % - -
-
- 覆盖加氢站数 - {filteredStations.length} 站 -
- - ) : ( - <> -
- 数据归集总量 - - {(filteredStations.reduce((s, st) => s + st.totalKg, 0) / 1000).toFixed(2)} T - -
-
- 数据总金额 - - ¥{filteredStations.reduce((s, st) => s + parseFloat(st.totalFeeWan), 0).toFixed(2)} 万元 - -
-
- 覆盖加氢站数 - {filteredStations.length} 站 -
-
- 数据源可追溯率 - 100% (含API/站点上报凭证) -
- - )} -
- - {/* 过滤筛选条:加氢站 / 客户 / 车辆(可搜索)+ 车辆归属 + 导出 */} -
-
- { - setStationFilter(v); - setCustomerFilter('all'); - setPlateFilter('all'); - }} - options={stationOptions} - allLabel="全部加氢站" - placeholder="搜索加氢站…" - width={200} - /> - { - setCustomerFilter(v); - setPlateFilter('all'); - }} - options={customerOptions} - allLabel="全部客户" - placeholder="搜索客户…" - width={200} - /> - -
- - - -
- -
- 提示:点击表格行可四级层层展开 【加氢站 → 客户 → 车牌 → 单笔订单与核对明细】 -
-
-
- -
‹ 左右滑动查看完整数据与凭证列 ›
- - {/* 穿透树形表格 */} -
- - - - - - - - - {isProfitDrill ? ( - <> - - - - - ) : isMonthDrill ? ( - <> - - - - - ) : isDayDrill ? ( - <> - - - - - ) : ( - <> - - - - )} - - - - {filteredStations.map((st) => { - const isStExpanded = !!expandedStations[st.stationId]; - const stationVehicles = st.customers.flatMap((c) => c.vehicles); - const stationVerify = aggregateVehiclesVerifyStatus(stationVehicles); - - return ( - - {/* Level 1: 加氢站 */} - toggleStation(st.stationId)} - > - - - - - - {renderMetricCells(st.totalKg, feeWanToYuan(st.totalFeeWan))} - - - {/* Level 2: 客户层 */} - {isStExpanded && - st.customers.map((cust) => { - const custKey = `${st.stationId}_${cust.customerId}`; - const isCustExpanded = !!expandedCustomers[custKey]; - - return ( - - toggleCustomer(st.stationId, cust.customerId)} - > - - - - - - {renderMetricCells(cust.totalKg, feeWanToYuan(cust.totalFeeWan))} - - - {/* Level 3: 车辆及数据来源与凭证层 (支持折叠展开单笔订单) */} - {isCustExpanded && - cust.vehicles.map((vh, idx) => { - const vhKey = `${st.stationId}_${cust.customerId}_${vh.plateNo}`; - const isVhExpanded = !!expandedVehicles[vhKey]; - const showAllOrders = !!expandedAllVehicleOrders[vhKey]; - const displayOrders = showAllOrders ? vh.orders : vh.orders.slice(0, 5); - const aggVerify = computeVehicleVerifyStatus(vh.orders, vh.fleetCategory); - // 无法识别车牌 →「无车牌」类目,与有牌车辆同级;标签固定「外部车辆」。能识别 →「羚牛车辆」/「外部车辆」按归属。 - const isNoPlate = !vh.plateNo || /无车牌/.test(vh.plateNo); - const plateDisplay = isNoPlate ? '无车牌' : vh.plateNo; - const isOwnFleet = !isNoPlate && vh.fleetCategory === 'own'; - - return ( - - toggleVehicle(st.stationId, cust.customerId, vh.plateNo)} - > - - - - - - {renderMetricCells(vh.kg, vh.amount)} - - - {/* Level 4: 单笔加氢订单流水与核对明细层 */} - {isVhExpanded && ( - <> - {displayOrders.map((ord) => ( - - - - - - - {renderMetricCells(ord.kg, ord.amount)} - - ))} - - - - - - )} - - ); - })} - - ); - })} - - ); - })} - -
加氢站 / 客户 / 车辆与凭证链路类型 / 归属数据来源及凭证号核对状态加氢笔数收入 (元)成本 (元)利润 (元)本月加氢量 (Kg)本月加氢费 (元)加氢费占年比本日加氢量 (Kg)本日加氢费 (元)加氢费占月比加氢总量 (Kg)加氢金额 (元)
- - {isStExpanded ? '▼' : '►'} - - {st.stationName} - - ({st.customers.length} 家客户) - - - - 全量自动归集 - {renderAggVerifyTag(stationVerify, '本站羚牛车辆')} - {st.customers.reduce((sum, c) => sum + c.vehicles.reduce((vs, v) => vs + v.count, 0), 0)} 笔 -
- - {isCustExpanded ? '▼' : '►'} - - └─ 客户:{cust.customerName} - -{cust.vehicles.length} 辆车挂载- - {cust.vehicles.reduce((sum, v) => sum + v.count, 0)} 笔 -
- - {isVhExpanded ? '▼' : '►'} - - {plateDisplay} - - ({vh.count} 笔订单) - - - {renderFleetTag(isOwnFleet)} - - {renderSourceTag(vh.source)} - - {renderVehicleVerifyTag(vh.fleetCategory, aggVerify)} - - {vh.count} 笔 -
- └── - 订单编号 - - {ord.orderId} - - ({ord.time}) - - - 单价 ¥{ord.unitPrice.toFixed(2)}/Kg - - - {renderSourceTag(ord.source)} - - {renderOrderVerifyTag(vh.fleetCategory, ord.verifyStatus)} - - 1 笔 -
- └── - - - - {showAllOrders - ? `包含该车辆共 ${vh.count} 笔历史加氢订单,已展示全量 ${vh.orders.length} 笔穿透流水` - : `包含该车辆共 ${vh.count} 笔历史加氢订单,默认展示近 ${displayOrders.length} 笔穿透核对流水明细`} - - -
-
- - )} -
-
-
- ); -} - -interface CustomerBillDrillModalProps { - customerName: string; - year: number; - onClose: () => void; -} - -function CustomerBillDrillModal({ customerName, year, onClose }: CustomerBillDrillModalProps) { - const [searchTerm, setSearchTerm] = useState(''); - const [expandedDates, setExpandedDates] = useState>({ - '2026-08-08': true, // 默认展开最新一日 - }); - - const custSummary = MOCK_CUSTOMER_SUMMARY_LIST.find((c) => c.name === customerName); - const bearerLabel = custSummary?.bearer === 'lingniu' ? '羚牛' : '客户'; - const summaryKgT = custSummary ? parseFloat(custSummary.kgT) : 0; - const summaryCostWan = custSummary ? parseFloat(custSummary.costWan) : 0; - const summaryReceivableText = custSummary?.receivable ?? '—'; - - const toggleDate = (dateStr: string) => { - setExpandedDates((prev) => ({ ...prev, [dateStr]: !prev[dateStr] })); - }; - - // 根据客户名称与年份建立【客户 -> 日期 -> 车牌加氢记录(最小粒度)】层层下钻明细 - const billData = useMemo(() => { - const dates = [ - { - date: '2026-08-08', - stationCount: 2, - records: [ - { plateNo: '浙A88888F', fleetCategory: 'own' as const, stationName: '嘉兴中石化滨海加氢站', time: '2026-08-08 08:15', kg: 45.2, receivable: 1356.0 }, - { plateNo: '浙A66666F', fleetCategory: 'own' as const, stationName: '嘉兴嘉锦加氢站', time: '2026-08-08 09:30', kg: 54.8, receivable: 1644.0 }, - { plateNo: '粤B12345D', fleetCategory: 'external' as const, stationName: '嘉兴中石化滨海加氢站', time: '2026-08-08 10:10', kg: 350.0, receivable: 10500.0 }, - { plateNo: '粤B99881D', fleetCategory: 'external' as const, stationName: '嘉兴嘉锦加氢站', time: '2026-08-08 14:20', kg: 280.0, receivable: 8400.0 }, - ], - }, - { - date: '2026-08-07', - stationCount: 1, - records: [ - { plateNo: '浙A88888F', fleetCategory: 'own' as const, stationName: '嘉兴中石化滨海加氢站', time: '2026-08-07 11:20', kg: 48.0, receivable: 1440.0 }, - { plateNo: '浙A33333F', fleetCategory: 'own' as const, stationName: '嘉兴中石化滨海加氢站', time: '2026-08-07 16:45', kg: 52.0, receivable: 1560.0 }, - { plateNo: '沪A66128D', fleetCategory: 'external' as const, stationName: '嘉兴中石化滨海加氢站', time: '2026-08-07 17:30', kg: 120.0, receivable: 3600.0 }, - ], - }, - { - date: '2026-08-06', - stationCount: 2, - records: [ - { plateNo: '浙A88888F', fleetCategory: 'own' as const, stationName: '嘉兴中石化滨海加氢站', time: '2026-08-06 09:10', kg: 42.5, receivable: 1275.0 }, - { plateNo: '川A88901', fleetCategory: 'external' as const, stationName: '成都中石化天府机场高速北站加氢站', time: '2026-08-06 13:15', kg: 210.0, receivable: 6300.0 }, - { plateNo: '川A88902', fleetCategory: 'external' as const, stationName: '成都中石化天府机场高速北站加氢站', time: '2026-08-06 15:50', kg: 180.0, receivable: 5400.0 }, - ], - }, - { - date: '2026-08-05', - stationCount: 1, - records: [ - { plateNo: '浙A66666F', fleetCategory: 'own' as const, stationName: '桐乡中石化绿能加氢站', time: '2026-08-05 10:40', kg: 50.0, receivable: 1500.0 }, - { plateNo: '渝A66881', fleetCategory: 'external' as const, stationName: '桐乡中石化绿能加氢站', time: '2026-08-05 14:05', kg: 310.0, receivable: 9300.0 }, - ], - }, - ]; - - return dates.map((d) => { - const filteredRecords = d.records.filter((r) => { - if (!searchTerm) return true; - const term = searchTerm.toLowerCase(); - return ( - (r.plateNo && r.plateNo.toLowerCase().includes(term)) || - r.stationName.toLowerCase().includes(term) - ); - }); - - const dayKg = filteredRecords.reduce((sum, r) => sum + r.kg, 0); - const dayReceivable = filteredRecords.reduce((sum, r) => sum + r.receivable, 0); - const dayCost = - summaryKgT > 0 - ? Math.round((dayKg / (summaryKgT * 1000)) * summaryCostWan * 10000 * 100) / 100 - : Math.round(dayReceivable * 0.9 * 100) / 100; - - return { - ...d, - records: filteredRecords, - totalKg: Math.round(dayKg * 10) / 10, - totalReceivable: Math.round(dayReceivable * 100) / 100, - totalCost: dayCost, - }; - }).filter((d) => d.records.length > 0); - }, [searchTerm, summaryKgT, summaryCostWan]); - - const totalKgSum = useMemo(() => { - return billData.reduce((sum, d) => sum + d.totalKg, 0); - }, [billData]); - - const totalReceivableSum = useMemo(() => { - return billData.reduce((sum, d) => sum + d.totalReceivable, 0); - }, [billData]); - - // 导出客户账单 Excel (.xlsx) - const handleExportExcel = () => { - const aoa: (string | number)[][] = [ - ['客户名称', '日期', '车牌号', '车辆归属', '加氢站', '加氢时间', '加氢量(Kg)', '应收(元)', '已收', '未收'], - ]; - - billData.forEach((d) => { - d.records.forEach((r) => { - aoa.push([ - customerName, - d.date, - r.plateNo || '无车牌(散车)', - r.fleetCategory === 'own' ? '羚牛车辆' : '外部车辆', - r.stationName, - r.time, - r.kg, - r.receivable, - '敬请期待 (对接账户)', - '敬请期待 (对接账单)', - ]); - }); - }); - - downloadExcelAoa(aoa, `客户账单穿透流水_${customerName}_${year}年.xlsx`, '客户账单穿透流水'); - }; - - return ( -
-
e.stopPropagation()}> - {/* Modal 头部 */} -
-
- -
-
- 「{customerName}」客户账单明细 -
-
-
- -
- -
-
- - {/* Modal 内容区 */} -
- {/* 列表关键字段汇总 */} -
-
- 承担方 - - {bearerLabel} - -
-
- 加氢量 - - {summaryKgT > 0 ? summaryKgT.toFixed(2) : (totalKgSum / 1000).toFixed(2)}{' '} - T - -
-
- 成本支出 - - ¥{summaryCostWan > 0 ? summaryCostWan.toFixed(2) : '—'}{' '} - 万元 - -
-
- 应收 - - {summaryReceivableText} - -
-
- 已收 - - 敬请期待 - -
-
- 未收 - - 敬请期待 - -
-
- - {/* 搜寻卡 */} -
-
-
- - setSearchTerm(e.target.value)} - /> - {searchTerm && ( - - )} -
- -
- 按日展开:加氢量 · 成本支出 · 应收 · 已收 · 未收 -
-
-
- -
‹ 左右滑动查看完整关键字段 ›
- - {/* 表格:对齐客户账单汇总关键字段 */} -
- - - - - - - - - - - - - - - {billData.map((day) => { - const isExpanded = !!expandedDates[day.date]; - - return ( - - {/* Level 2: 日期层 */} - toggleDate(day.date)} - > - - - - - - - - - - - {/* Level 3: 车牌加氢记录 (最小粒度) */} - {isExpanded && - day.records.map((rec, rIdx) => { - const rowCost = - day.totalKg > 0 - ? Math.round((rec.kg / day.totalKg) * day.totalCost * 100) / 100 - : 0; - return ( - - - - - - - - - - - ); - })} - - ); - })} - -
日期 / 车牌明细加氢站承担方加氢量(Kg)成本支出(元)应收(元)已收未收
-
- {isExpanded ? '▼' : '►'} - 📅 {day.date} -
-
- 涉及 {day.stationCount} 个加氢站 · {day.records.length} 笔 - - - {bearerLabel} - - - {day.totalKg.toLocaleString('zh-CN')} Kg - - ¥{day.totalCost.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} - - ¥{day.totalReceivable.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} - - - 敬请期待 - - - - 敬请期待 - -
-
- └── - - {rec.plateNo || '无车牌(散车)'} - - - {rec.time} - -
-
{rec.stationName} - - {bearerLabel} - - - {rec.kg.toFixed(1)} Kg - - ¥{rowCost.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} - - ¥{rec.receivable.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} - - 敬请期待 - - 敬请期待 -
-
-
-
-
- ); -} - -interface StationBillDrillModalProps { - stationName: string; - province: string; - year: number; - onClose: () => void; -} - -function StationBillDrillModal({ stationName, province, year, onClose }: StationBillDrillModalProps) { - const [searchTerm, setSearchTerm] = useState(''); - const [expandedDates, setExpandedDates] = useState>({ - '2026-08-08': true, // 默认展开最新一日 - }); - - const stSummary = - MOCK_STATION_SUMMARY_LIST.find((s) => s.name === stationName) || - MOCK_STATION_SUMMARY_LIST.find((s) => stationName.includes(s.name.slice(0, 8)) || s.name.includes(stationName.slice(0, 8))); - const summaryKgT = stSummary ? parseFloat(stSummary.kgT) : 0; - const summaryKgPct = stSummary?.kgPct ?? 0; - const summaryIncomeWan = stSummary ? parseFloat(stSummary.incomeWan) : 0; - const summaryIncomePct = stSummary?.incomePct ?? 0; - - const toggleDate = (dateStr: string) => { - setExpandedDates((prev) => ({ ...prev, [dateStr]: !prev[dateStr] })); - }; - - // 全站累计基准总量 (Kg) 与 总氢费收入 (元) — 优先取汇总表关键字段 - const totalStationKg = summaryKgT > 0 ? Math.round(summaryKgT * 1000) : 243660; - const totalStationIncome = summaryIncomeWan > 0 ? Math.round(summaryIncomeWan * 10000) : 526600; - - // 根据加氢站构建【加氢站 -> 所有日期的加氢量、占比、氢费收入、收入占比】下钻明细 - const stationData = useMemo(() => { - const dates = [ - { - date: '2026-08-08', - records: [ - { plateNo: '浙A88888F', fleetCategory: 'own' as const, customerName: '羚牛氢能科技(广东)有限公司', time: '2026-08-08 08:15', kg: 450.2, income: 13506.0 }, - { plateNo: '浙A66666F', fleetCategory: 'own' as const, customerName: '嘉兴市乍浦港口经营有限公司', time: '2026-08-08 09:30', kg: 540.8, income: 16224.0 }, - { plateNo: '粤B12345D', fleetCategory: 'external' as const, customerName: '广东氢动力科技服务有限公司', time: '2026-08-08 10:10', kg: 350.0, income: 10500.0 }, - { plateNo: '沪A66128D', fleetCategory: 'external' as const, customerName: '上海明纳物流有限公司', time: '2026-08-08 14:20', kg: 280.0, income: 8400.0 }, - ], - }, - { - date: '2026-08-07', - records: [ - { plateNo: '浙A88888F', fleetCategory: 'own' as const, customerName: '羚牛氢能科技(广东)有限公司', time: '2026-08-07 11:20', kg: 480.0, income: 14400.0 }, - { plateNo: '浙A33333F', fleetCategory: 'own' as const, customerName: '嘉兴益顺冷链物流有限公司', time: '2026-08-07 16:45', kg: 520.0, income: 15600.0 }, - { plateNo: '粤B99881D', fleetCategory: 'external' as const, customerName: '嘉兴智奇供应链管理有限公司', time: '2026-08-07 17:30', kg: 410.0, income: 12300.0 }, - ], - }, - { - date: '2026-08-06', - records: [ - { plateNo: '浙A88888F', fleetCategory: 'own' as const, customerName: '羚牛氢能科技(广东)有限公司', time: '2026-08-06 09:10', kg: 425.0, income: 12750.0 }, - { plateNo: '川A88901', fleetCategory: 'external' as const, customerName: '四川群彬物流有限公司', time: '2026-08-06 13:15', kg: 610.0, income: 18300.0 }, - { plateNo: '川A88902', fleetCategory: 'external' as const, customerName: '四川拱照物流有限公司', time: '2026-08-06 15:50', kg: 580.0, income: 17400.0 }, - ], - }, - { - date: '2026-08-05', - records: [ - { plateNo: '浙A66666F', fleetCategory: 'own' as const, customerName: '嘉兴市乍浦港口经营有限公司', time: '2026-08-05 10:40', kg: 500.0, income: 15000.0 }, - { plateNo: '渝A66881', fleetCategory: 'external' as const, customerName: '重庆金时源供应链有限公司', time: '2026-08-05 14:05', kg: 710.0, income: 21300.0 }, - ], - }, - ]; - - return dates.map((d) => { - const filteredRecords = d.records.filter((r) => { - if (!searchTerm) return true; - const term = searchTerm.toLowerCase(); - return ( - d.date.includes(term) || - (r.plateNo && r.plateNo.toLowerCase().includes(term)) || - r.customerName.toLowerCase().includes(term) - ); - }); - - const dayKg = filteredRecords.reduce((sum, r) => sum + r.kg, 0); - const dayIncome = filteredRecords.reduce((sum, r) => sum + r.income, 0); - - const dayKgPct = Math.round((dayKg / totalStationKg) * 10000) / 100; - const dayIncomePct = Math.round((dayIncome / totalStationIncome) * 10000) / 100; - - return { - ...d, - records: filteredRecords, - totalKg: Math.round(dayKg * 10) / 10, - totalKgPct: dayKgPct, - totalIncome: Math.round(dayIncome * 100) / 100, - totalIncomePct: dayIncomePct, - }; - }).filter((d) => d.records.length > 0); - }, [searchTerm]); - - const totalKgSum = useMemo(() => { - return stationData.reduce((sum, d) => sum + d.totalKg, 0); - }, [stationData]); - - const totalIncomeSum = useMemo(() => { - return stationData.reduce((sum, d) => sum + d.totalIncome, 0); - }, [stationData]); - - // 导出加氢站按日账单 Excel (.xlsx) - const handleExportExcel = () => { - const aoa: (string | number)[][] = [ - ['加氢站名称', '所属省份', '日期', '车牌号', '车辆归属', '关联客户', '加氢时间', '加氢量(Kg)', '加氢量占比', '氢费收入(元)', '收入占比'], - ]; - - stationData.forEach((d) => { - d.records.forEach((r) => { - const rKgPct = ((r.kg / totalStationKg) * 100).toFixed(2) + '%'; - const rIncomePct = ((r.income / totalStationIncome) * 100).toFixed(2) + '%'; - aoa.push([ - stationName, - province, - d.date, - r.plateNo || '无车牌(散车)', - r.fleetCategory === 'own' ? '羚牛车辆' : '外部车辆', - r.customerName, - r.time, - r.kg, - rKgPct, - r.income, - rIncomePct, - ]); - }); - }); - - downloadExcelAoa(aoa, `加氢站按日账单穿透_${stationName}_${year}年.xlsx`, '站按日账单穿透'); - }; - - return ( -
-
e.stopPropagation()}> - {/* Modal 头部 */} -
-
- -
-
- 「{stationName}」加氢汇总明细 -
-
-
- -
- -
-
- - {/* Modal 内容区 */} -
- {/* 列表关键字段:加氢量 / 占比 / 氢费收入 / 收入占比 */} -
-
- 所属省份 - - {province} - -
-
- 加氢量 - - {(totalStationKg / 1000).toFixed(2)} T - -
-
- 占比 - - {summaryKgPct.toFixed(1)}% - -
-
- 氢费收入 - - ¥{(totalStationIncome / 10000).toFixed(2)} 万元 - -
-
- 收入占比 - - {summaryIncomePct.toFixed(1)}% - -
-
- - {/* 搜寻卡 */} -
-
-
- - setSearchTerm(e.target.value)} - /> - {searchTerm && ( - - )} -
- -
- 按日展开关键字段:加氢量 · 占比 · 氢费收入 · 收入占比 -
-
-
- -
‹ 左右滑动查看完整数据与状态 ›
- - {/* 表格:对齐加氢站汇总关键字段(按日) */} -
- - - - - - - - - - - - - - {stationData.map((day) => { - const isExpanded = !!expandedDates[day.date]; - - return ( - - {/* Level 1: 所有日期的加氢量、占比、氢费收入、收入占比 */} - toggleDate(day.date)} - > - - - - - - - - - - {/* Level 2: 车牌/客户加氢记录 (最小粒度) */} - {isExpanded && - day.records.map((rec, rIdx) => { - const rKgPct = Math.round((rec.kg / totalStationKg) * 10000) / 100; - const rIncomePct = Math.round((rec.income / totalStationIncome) * 10000) / 100; - - return ( - - - - - - - - - - ); - })} - - ); - })} - -
日期 / 车牌明细关联客户 / 车辆归属加氢时间加氢量(Kg)加氢量占比氢费收入(元)收入占比
-
- {isExpanded ? '▼' : '►'} - 📅 {day.date} -
-
- {day.records.length} 笔车辆加氢发生 - 当天汇总 - {day.totalKg.toLocaleString('zh-CN')} Kg - -
-
-
-
- {day.totalKgPct.toFixed(2)}% -
-
- ¥{day.totalIncome.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} - -
-
-
-
- {day.totalIncomePct.toFixed(2)}% -
-
-
- └── - - {rec.plateNo || '无车牌(散车)'} - - - {rec.fleetCategory === 'own' ? '羚牛' : '外部'} - -
-
{rec.customerName} - {rec.time} - - {rec.kg.toFixed(1)} Kg - - {rKgPct.toFixed(2)}% - - ¥{rec.income.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} - - {rIncomePct.toFixed(2)}% -
-
-
-
-
- ); -} - -function StationMonthTable({ - rows, - activeId, - onOpen, -}: { - rows: ReturnType; - activeId: string | null; - onOpen: (id: string, label: string) => void; -}) { - if (!rows.length) return
本筛选下暂无站月发生额
; - return ( -
- - - - - - - - - - - - - {rows.map((r, i) => ( - onOpen(r.stationId, r.stationName)} - > - - - - - - - - ))} - -
#加氢站月份发生额加氢量未核金额
{i + 1}{r.stationName}{r.month}{formatYuan(r.amount)}{formatKg(r.quantityKg)}{formatYuan(r.unverifiedAmount)}
-
- ); -} - -function CustomerAttrTable({ - rows, - activeId, - onOpen, -}: { - rows: ReturnType; - activeId: string | null; - onOpen: (id: string, label: string) => void; -}) { - if (!rows.length) return
本筛选下暂无客户归属
; - return ( -
- - - - - - - - - - - - - {rows.map((r, i) => ( - onOpen(r.customerId, r.customerName)} - > - - - - - - - - ))} - -
#客户承担方加氢量我司成本未核
{i + 1}{r.customerName}{r.borneLabel}{formatKg(r.quantityKg)}{formatYuan(r.companyCost)}{formatYuan(r.unverifiedAmount)}
-
- ); -} - -function OrderTable({ rows }: { rows: H2OrderRow[] }) { - if (!rows.length) { - return
本筛选下暂无我司成本明细
; - } - return ( -
- - - - - - - - - - - - - - - - {rows.map((r) => ( - - - - - - - - - - - - ))} - -
时间加氢站车牌客户加氢量金额成本维度核对来源
{r.occurredAt}{r.stationName}{r.plateNo}{r.customerName}{formatKg(r.quantityKg)}{formatYuan(r.amount)}{costDimLabel(r)} - - {r.verifyStatus === 'verified' ? '已核对' : '未核对'} - - {SOURCE_LABEL[r.source]}
-
- ); -} - -interface BiCustomDatePickerProps { - label: string; - value: string; // YYYY-MM-DD | YYYY-MM | YYYY - onChange: (dateStr: string) => void; -} - -function BiCustomDatePicker({ label, value, onChange }: BiCustomDatePickerProps) { - const [isOpen, setIsOpen] = useState(false); - const containerRef = useRef(null); - - // 面板视图模式:'day' | 'month' | 'year' - const [pickerMode, setPickerMode] = useState<'day' | 'month' | 'year'>('day'); - - const parsedDate = useMemo(() => { - const parts = value.split('-'); - const year = parseInt(parts[0], 10) || 2026; - const month = parseInt(parts[1], 10) || 8; - const day = parseInt(parts[2], 10) || 1; - return { year, month, day }; - }, [value]); - - const [viewYear, setViewYear] = useState(parsedDate.year); - const [viewMonth, setViewMonth] = useState(parsedDate.month); - - useEffect(() => { - if (isOpen) { - setViewYear(parsedDate.year); - setViewMonth(parsedDate.month); - const parts = value.split('-'); - if (parts.length === 1 && value.length === 4) { - setPickerMode('year'); - } else if (parts.length === 2) { - setPickerMode('month'); - } else { - setPickerMode('day'); - } - } - }, [isOpen, value, parsedDate]); - - useEffect(() => { - function handleClickOutside(e: MouseEvent) { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { - setIsOpen(false); - } - } - if (isOpen) { - document.addEventListener('mousedown', handleClickOutside); - } - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [isOpen]); - - const yearsList = [2026, 2025, 2024, 2023, 2022, 2021, 2020]; - const monthsList = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; - - const handleSelectYear = (y: number, e: React.MouseEvent) => { - e.stopPropagation(); - setViewYear(y); - if (pickerMode === 'year') { - onChange(`${y}`); - setIsOpen(false); - } else { - setPickerMode(pickerMode === 'day' ? 'month' : 'day'); - } - }; - - const handleSelectMonth = (m: number, e: React.MouseEvent) => { - e.stopPropagation(); - setViewMonth(m); - const mm = m < 10 ? `0${m}` : `${m}`; - if (pickerMode === 'month') { - onChange(`${viewYear}-${mm}`); - setIsOpen(false); - } else { - setPickerMode('day'); - } - }; - - const handleSelectDay = (d: number, e: React.MouseEvent) => { - e.stopPropagation(); - const mm = viewMonth < 10 ? `0${viewMonth}` : `${viewMonth}`; - const dd = d < 10 ? `0${d}` : `${d}`; - onChange(`${viewYear}-${mm}-${dd}`); - setIsOpen(false); - }; - - const handlePrev = (e: React.MouseEvent) => { - e.stopPropagation(); - if (pickerMode === 'day') { - if (viewMonth === 1) { - setViewYear((prev) => prev - 1); - setViewMonth(12); - } else { - setViewMonth((prev) => prev - 1); - } - } else { - setViewYear((prev) => prev - 1); - } - }; - - const handleNext = (e: React.MouseEvent) => { - e.stopPropagation(); - if (pickerMode === 'day') { - if (viewMonth === 12) { - setViewYear((prev) => prev + 1); - setViewMonth(1); - } else { - setViewMonth((prev) => prev + 1); - } - } else { - setViewYear((prev) => prev + 1); - } - }; - - const daysInMonth = new Date(viewYear, viewMonth, 0).getDate(); - const firstDayWeek = new Date(viewYear, viewMonth - 1, 1).getDay(); - const daysArray = Array.from({ length: daysInMonth }, (_, i) => i + 1); - const emptyPrefixSlots = Array.from({ length: firstDayWeek }, (_, i) => i); - - return ( -
-
setIsOpen(!isOpen)} - > - {label} - {value} - -
- - {isOpen && ( -
- {/* 1. 粒度模式选择器: 按日 | 按月 | 按年 */} -
- - - -
- - {/* 2. 标头快速切年月 */} -
- -
- - {pickerMode === 'day' && ( - - )} -
- -
- - {/* 3. 日视图 */} - {pickerMode === 'day' && ( - <> -
- - - - - - - -
- -
- {emptyPrefixSlots.map((s) => ( - - ))} - {daysArray.map((d) => { - const mm = viewMonth < 10 ? `0${viewMonth}` : `${viewMonth}`; - const dd = d < 10 ? `0${d}` : `${d}`; - const isSelected = value === `${viewYear}-${mm}-${dd}`; - - return ( - - ); - })} -
- - )} - - {/* 4. 月视图 */} - {pickerMode === 'month' && ( -
- {monthsList.map((m) => { - const mm = m < 10 ? `0${m}` : `${m}`; - const isSelected = value === `${viewYear}-${mm}` || (value.split('-').length === 3 && parsedDate.year === viewYear && parsedDate.month === m); - - return ( - - ); - })} -
- )} - - {/* 5. 年视图 */} - {pickerMode === 'year' && ( -
- {yearsList.map((y) => { - const isSelected = value === `${y}` || parsedDate.year === y; - - return ( - - ); - })} -
- )} -
- )} -
- ); -} - -interface HostDailyViewProps { - updatedAt?: string; - onRefresh?: () => void; - startDate: string; - endDate: string; - onStartDateChange: (val: string) => void; - onEndDateChange: (val: string) => void; -} - -function HostDailyView({ - updatedAt, - onRefresh, - startDate, - endDate, - onStartDateChange, - onEndDateChange, -}: HostDailyViewProps) { - const [rangePreset, setRangePreset] = useState<'week' | 'month' | '15days' | 'custom'>('15days'); - const [fleetType, setFleetType] = useState('all'); - - // 上方时间预设连动 KPI 卡片标题 - const kpiRangeTitle = useMemo(() => { - if (rangePreset === 'week') return '本周加氢量'; - if (rangePreset === 'month') return '本月加氢量'; - if (rangePreset === '15days') return '近 15 天加氢量'; - return '自定义区间加氢量'; - }, [rangePreset]); - - const handlePresetChange = (preset: 'week' | 'month' | '15days' | 'custom') => { - setRangePreset(preset); - if (preset === 'week') { - onStartDateChange('2026-08-03'); - onEndDateChange('2026-08-08'); - } else if (preset === 'month') { - onStartDateChange('2026-08-01'); - onEndDateChange('2026-08-08'); - } else if (preset === '15days') { - onStartDateChange('2026-07-25'); - onEndDateChange('2026-08-08'); - } - }; - - // 日期归一化转换(兼容手选 年 YYYY、月 YYYY-MM、日 YYYY-MM-DD) - const normalizeDateStr = (dateStr: string, isEnd: boolean) => { - if (!dateStr) return isEnd ? '9999-12-31' : '0000-01-01'; - const parts = dateStr.split('-'); - if (parts.length === 1) { - return isEnd ? `${parts[0]}-12-31` : `${parts[0]}-01-01`; - } - if (parts.length === 2) { - const y = parseInt(parts[0], 10); - const m = parseInt(parts[1], 10); - if (isEnd) { - const lastDay = new Date(y, m, 0).getDate(); - const dd = lastDay < 10 ? `0${lastDay}` : `${lastDay}`; - return `${parts[0]}-${parts[1]}-${dd}`; - } - return `${parts[0]}-${parts[1]}-01`; - } - return dateStr; - }; - - const normStart = useMemo(() => normalizeDateStr(startDate, false), [startDate]); - const normEnd = useMemo(() => normalizeDateStr(endDate, true), [endDate]); - - // 1. 根据 startDate & endDate 动态生成或提取指定日期范围内的全量每日加氢数据列表 - const dateFilteredList = useMemo(() => { - return getDailyDataForRange(normStart, normEnd); - }, [normStart, normEnd]); - - // 2. 根据 fleetType 过滤出对应车辆归属下的加氢列表 ('all' 时包含内部与外部合并显示) - const filteredDailyList = useMemo(() => { - return filterDailyDataByFleet(dateFilteredList, fleetType); - }, [dateFilteredList, fleetType]); - - // 2. 动态计算关联的 KPI 及柱图统计数据 - const dailyKpis = useMemo(() => { - return calculateDailyKpis(filteredDailyList, fleetType); - }, [filteredDailyList, fleetType]); - - // 深层折叠/展开状态 - const [expandedDate, setExpandedDate] = useState('2026-08-08'); // 默认展开最新一天 - const [expandedStations, setExpandedStations] = useState>({ - '2026-08-08_st-jx': true, // 默认展开嘉兴站,演示效果 - }); - const [expandedCustomers, setExpandedCustomers] = useState>({ - '2026-08-08_st-jx_c-ln': true, // 默认展开羚牛客户,直观展示车辆与数据来源 - }); - - // 点击柱状图后的高亮锚点状态 - const [highlightedDate, setHighlightedDate] = useState(null); - - // 保证当前选中的展开日期始终在当前过滤数据集中 - useEffect(() => { - if (filteredDailyList.length > 0 && (!expandedDate || !filteredDailyList.some((d) => d.date === expandedDate))) { - setExpandedDate(filteredDailyList[0].date); - } - }, [filteredDailyList, expandedDate]); - - const maxQty = useMemo(() => { - if (!filteredDailyList.length) return 4000; - return Math.max(...filteredDailyList.map((d) => d.quantityKg), 3000); - }, [filteredDailyList]); - - const totalSum = useMemo(() => { - const sum = filteredDailyList.reduce((acc, item) => acc + item.quantityKg, 0); - return sum.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); - }, [filteredDailyList]); - - /** 点击柱状图上的柱子:展开该日、锚点平滑滚动并高亮 */ - const handleBarClick = (date: string) => { - setExpandedDate(date); - setHighlightedDate(date); - - setTimeout(() => { - const el = document.getElementById(`daily-row-${date}`); - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'center' }); - } - }, 60); - - setTimeout(() => { - setHighlightedDate((prev) => (prev === date ? null : prev)); - }, 2000); - }; - - const toggleStation = (dateKey: string, stationId: string, e: React.MouseEvent) => { - e.stopPropagation(); - const key = `${dateKey}_${stationId}`; - setExpandedStations((prev) => ({ ...prev, [key]: !prev[key] })); - }; - - const toggleCustomer = (dateKey: string, stationId: string, customerId: string, e: React.MouseEvent) => { - e.stopPropagation(); - const key = `${dateKey}_${stationId}_${customerId}`; - setExpandedCustomers((prev) => ({ ...prev, [key]: !prev[key] })); - }; - - /** 导出按日加氢数据明细为 Excel (.xlsx) */ - const handleExportExcel = () => { - const aoa: (string | number)[][] = [ - ['日期', '加氢站名称', '加氢站类型', '客户名称', '客户属性', '加氢时间', '车牌号', '车辆归属', '数据来源', '核对状态', '单价(元/Kg)', '加氢量(Kg)', '加氢金额(元)', '预充值余额'], - ]; - - filteredDailyList.forEach((d) => { - d.stations.forEach((st) => { - const stationPrecharge = st.prechargeBalance ?? (st.stationId.includes('jx') ? 128500 : st.stationId.includes('tx') ? 86200 : 45000); - st.customers?.forEach((cust) => { - const isInternalCust = cust.customerCategory === 'internal' || cust.customerId === 'c-ln'; - cust.vehicles?.forEach((vh) => { - aoa.push([ - d.date, - st.stationName, - STATION_TYPE_LABEL[st.stationType] || st.stationType, - cust.customerName, - isInternalCust ? '内部客户' : '外部客户', - vh.time, - vh.plateNo || '无车牌(散车)', - vh.fleetCategory === 'own' ? '羚牛车辆' : '外部车辆', - SOURCE_TYPE_LABEL[vh.source] || vh.source, - vh.fleetCategory === 'own' ? (DAILY_VERIFY_LABEL[vh.verifyStatus || 'unverified'] || '未核对') : '-', - vh.unitPrice, - vh.quantityKg, - vh.amountYuan, - `¥${stationPrecharge.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} (对接站点管理)`, - ]); - }); - }); - }); - }); - - const fileDateStr = `${startDate.replace(/[/]/g, '')}-${endDate.replace(/[/]/g, '')}`; - const fleetName = fleetType === 'all' ? '全部车辆' : fleetType === 'own' ? '羚牛车辆' : '外部车辆'; - downloadExcelAoa(aoa, `每日加氢数据明细_${fleetName}_${fileDateStr}.xlsx`, '每日加氢明细'); - }; - - return ( -
- {/* 1. 顶栏时间/范围筛选器 */} -
-
-
-
- - - - -
- - { - onStartDateChange(val); - setRangePreset('custom'); - }} - /> - { - onEndDateChange(val); - setRangePreset('custom'); - }} - /> -
- -
-
- - - -
- - {updatedAt && ( - - {updatedAt} - - )} - - -
-
-
- - {/* 2. 4卡 Bento KPI */} -
-
-
- {kpiRangeTitle} - - - -
-
- {dailyKpis.totalQuantityKg.toLocaleString('zh-CN')} - Kg -
-
{dailyKpis.dateRange}
-
- -
-
- 车辆结构 - - - -
-
- - {dailyKpis.fleetTypeLabel} - -
-
{dailyKpis.fleetSubLabel}
-
- -
-
- 有效天数 - - - -
-
- {dailyKpis.activeDays} -
-
日均 {dailyKpis.dailyAvgKg}
-
- -
-
- 涉及加氢站 - - - -
-
- {dailyKpis.stationCount} - -
-
按明细站点去重
-
-
- - {/* 3. 每日加氢量堆积柱状图(分别显示内部客户与外部客户加氢量,点击柱子下锚定位) */} -
-
-
- 每日加氢量 - (点击柱体下锚定位到对应日期明细) - (点击柱体定位) -
-
-
- - - 内部客户 - - - - 外部客户 - -
- 时间单位:日 · 单位 Kg -
-
- -
-
- 峰值日 - {dailyKpis.peakDayLabel} -
-
- 低谷日 - {dailyKpis.troughDayLabel} -
-
- 零数日 - {dailyKpis.zeroDaysCount} 天 -
-
- -
‹ 左右滑动查看每日加氢趋势 ›
- -
-
0 ? Math.min(92, Math.round((dailyKpis.dailyAvgKgNum / maxQty) * 100)) : 50}%`, - }} - > - 均值 {dailyKpis.dailyAvgKg} -
- - {[...filteredDailyList].reverse().map((item) => { - // 计算当天内部客户加氢量与外部客户加氢量 - let dayOwnKg = 0; - let dayExtKg = 0; - item.stations.forEach((st) => { - st.customers.forEach((cust) => { - cust.vehicles.forEach((vh) => { - if (vh.fleetCategory === 'own') { - dayOwnKg += vh.quantityKg; - } else { - dayExtKg += vh.quantityKg; - } - }); - }); - }); - dayOwnKg = Math.round(dayOwnKg * 10) / 10; - dayExtKg = Math.round(dayExtKg * 10) / 10; - - const totalKg = item.quantityKg > 0 ? item.quantityKg : 1; - const pct = Math.min(100, Math.round((item.quantityKg / maxQty) * 100)); - const ownRatio = Math.round((dayOwnKg / totalKg) * 100); - const extRatio = Math.max(0, 100 - ownRatio); - - const isBarActive = expandedDate === item.date; - - const tooltipText = `${item.date} 加氢总量 ${Math.round(item.quantityKg).toLocaleString('zh-CN')} Kg\n├─ 内部客户: ${Math.round(dayOwnKg).toLocaleString('zh-CN')} Kg (${ownRatio}%)\n└─ 外部客户: ${Math.round(dayExtKg).toLocaleString('zh-CN')} Kg (${extRatio}%)\n(点击下锚定位到该日明细)`; - - return ( -
handleBarClick(item.date)} - > -
- {Math.round(item.quantityKg)} -
- - {/* 堆积柱体:上部外部客户,下部内部客户 */} -
- {dayExtKg > 0 && ( -
- )} - {dayOwnKg > 0 && ( -
- )} -
- -
- {item.shortDate} -
-
- ); - })} -
-
- - {/* 4. 每日数据明细多层钻取表格 */} -
-
-
- 每日加氢数据明细 - (可多层下钻:按日 → 加氢站 → 客户 → 车辆及数据源) - (可逐级下钻) -
- -
- -
- - - - - - - - - - - - {/* 合计行 */} - - - - - - - - - {filteredDailyList.map((row) => { - const isDateExpanded = expandedDate === row.date; - const isHighlighted = highlightedDate === row.date; - - return ( - - {/* Level 1: 日期行 */} - setExpandedDate(isDateExpanded ? null : row.date)} - > - - - - - - - - {/* Level 2: 加氢站层 */} - {isDateExpanded && - row.stations.map((st) => { - const stKey = `${row.date}_${st.stationId}`; - const isStExpanded = !!expandedStations[stKey]; - const stPrecharge = st.prechargeBalance ?? (st.stationId.includes('jx') ? 128500 : st.stationId.includes('tx') ? 86200 : 45000); - - return ( - - toggleStation(row.date, st.stationId, e)} - > - - - - - - - - {/* Level 3: 客户层 */} - {isStExpanded && - st.customers?.map((cust) => { - const custKey = `${row.date}_${st.stationId}_${cust.customerId}`; - const isCustExpanded = !!expandedCustomers[custKey]; - const isInternalCust = cust.customerCategory === 'internal' || cust.customerId === 'c-ln'; - - return ( - - toggleCustomer(row.date, st.stationId, cust.customerId, e)} - > - - - - - - - - {/* Level 4: 车辆及数据来源明细层 */} - {isCustExpanded && - cust.vehicles?.map((vh) => ( - - - - - - - - ))} - - ); - })} - - ); - })} - - ); - })} - -
日期 / 加氢站 / 客户 / 车辆明细单价 (元/Kg)加氢量 (Kg)金额 (元) / 环比预充值余额
- 合计 - {totalSum} - 对接站点管理 -
- - {row.stations.length > 0 ? (isDateExpanded ? '▼' : '►') : '•'} - - {row.date} - - ({row.stations.length} 个加氢站) - - {row.unitPrice.toFixed(2)} - {row.quantityKg.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} - - {row.momPct !== null ? ( - - {row.momPct > 0 ? `+${row.momPct.toFixed(1)}%` : `${row.momPct.toFixed(1)}%`} - - ) : ( - '-' - )} - -
- - {st.customers?.length ? (isStExpanded ? '▼' : '►') : '•'} - - └ {st.stationName} - - {st.unitPrice.toFixed(2)} - - {st.quantityKg.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} - - ¥{st.amountYuan.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} - - ¥{stPrecharge.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} -
-
- - - {cust.vehicles?.length ? (isCustExpanded ? '▼' : '►') : '•'} - - └─ 客户:{cust.customerName} - - {isInternalCust ? ( - - 内部客户 - {cust.quantityKg.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} Kg - ¥{cust.amountYuan.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} - - ) : ( - - 外部客户 - {cust.quantityKg.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} Kg - ¥{cust.amountYuan.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} - - )} -
-
- - {cust.quantityKg.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} - - ¥{cust.amountYuan.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} - -
- └── - - {vh.time} - - {vh.plateNo ? ( - - {vh.plateNo} - - ) : ( - - 无车牌(散车) - - )} - - {renderFleetTag(vh.fleetCategory === 'own')} - - - {renderSourceTag(vh.source)} - - {/* 规则:仅内部车辆(羚牛车辆)展示核对状态;外部车辆不参与核对 */} - {vh.fleetCategory === 'own' && vh.verifyStatus && ( - {renderOrderVerifyTag(vh.fleetCategory, vh.verifyStatus)} - )} - - {vh.unitPrice.toFixed(2)} - - {vh.quantityKg.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} - - ¥{vh.amountYuan.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} - -
-
-
-
- ); -} diff --git a/src/modules/energy/hydrogen-bi-v2/prototype-source/annotation-source.json b/src/modules/energy/hydrogen-bi-v2/prototype-source/annotation-source.json deleted file mode 100644 index 34c54f7..0000000 --- a/src/modules/energy/hydrogen-bi-v2/prototype-source/annotation-source.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "directory": { - "title": "能源氢费经营看板", - "nodes": [ - { - "id": "overview", - "title": "产品需求说明(PRD)", - "type": "markdown", - "path": ".spec/requirements-prd.md", - "description": "口令 lingniu · 顶栏全局/单站 · 无关联入口卡 · 默认全部车辆·KPI/图表/钻取跟随筛选 · 无车牌归外部车辆 · 外部主数据见 energy-h2-external-* · 分流 own→氢费明细 external→仅 BI" - }, - { - "id": "board-app", - "title": "氢能经营看板", - "type": "route", - "path": "/prototypes/energy-h2-bi-board", - "description": "顶栏全局/单站;单站=日报起止查询·行内加氢量占比·按量降序" - }, - { - "id": "energy-board-plan", - "title": "能源 BI 看板方案", - "type": "markdown", - "path": "../../resources/prd/energy-board-plan-20260806.md" - }, - { - "id": "host-ref", - "title": "宿主 overview 视觉参考", - "type": "markdown", - "path": "../../resources/prd/energy-bi-host-ref/content.md" - } - ] - } -} diff --git a/src/modules/energy/hydrogen-bi-v2/prototype-source/data/aggregates.ts b/src/modules/energy/hydrogen-bi-v2/prototype-source/data/aggregates.ts deleted file mode 100644 index c8769ac..0000000 --- a/src/modules/energy/hydrogen-bi-v2/prototype-source/data/aggregates.ts +++ /dev/null @@ -1,283 +0,0 @@ -import type { - CostDim, - FleetScope, - H2OrderRow, - LeaseKind, - OpsKind, -} from '../types'; -import { COST_DIM_LABEL, LEASE_KIND_LABEL, OPS_KIND_LABEL } from '../types'; - -export function filterOrders( - rows: H2OrderRow[], - year: number, - verifyScope: 'all' | 'verified', - fleetScope: FleetScope, -): H2OrderRow[] { - return rows.filter((r) => { - if (!r.occurredAt.startsWith(String(year))) return false; - if (verifyScope === 'verified' && r.verifyStatus !== 'verified') return false; - if (fleetScope === 'own' && r.fleet !== 'own') return false; - if (fleetScope === 'external' && r.fleet !== 'external') return false; - return true; - }); -} - -export function sumAmount(rows: H2OrderRow[]): number { - return rows.reduce((s, r) => s + r.amount, 0); -} - -export function sumKg(rows: H2OrderRow[]): number { - return rows.reduce((s, r) => s + r.quantityKg, 0); -} - -export function companyCostRows(rows: H2OrderRow[]): H2OrderRow[] { - return rows.filter((r) => r.borneBy === 'company'); -} - -export function dimAmount(rows: H2OrderRow[], dim: CostDim): number { - return sumAmount(companyCostRows(rows).filter((r) => r.costDim === dim)); -} - -export function leaseSubAmount(rows: H2OrderRow[], kind: LeaseKind): number { - return sumAmount( - companyCostRows(rows).filter((r) => r.costDim === 'lease' && r.leaseKind === kind), - ); -} - -export function opsSubAmount(rows: H2OrderRow[], kind: OpsKind): number { - return sumAmount( - companyCostRows(rows).filter((r) => r.costDim === 'ops' && r.opsKind === kind), - ); -} - -export interface DimCard { - key: CostDim; - label: string; - amount: number; - subs: { key: string; label: string; amount: number }[]; -} - -export function costDimCards(rows: H2OrderRow[]): DimCard[] { - return [ - { - key: 'lease', - label: COST_DIM_LABEL.lease, - amount: dimAmount(rows, 'lease'), - subs: [ - { key: 'company_borne', label: LEASE_KIND_LABEL.company_borne, amount: leaseSubAmount(rows, 'company_borne') }, - { key: 'package_h2', label: LEASE_KIND_LABEL.package_h2, amount: leaseSubAmount(rows, 'package_h2') }, - ], - }, - { - key: 'logistics', - label: COST_DIM_LABEL.logistics, - amount: dimAmount(rows, 'logistics'), - subs: [], - }, - { - key: 'ops', - label: COST_DIM_LABEL.ops, - amount: dimAmount(rows, 'ops'), - subs: [ - { key: 'abnormal', label: OPS_KIND_LABEL.abnormal, amount: opsSubAmount(rows, 'abnormal') }, - { key: 'transfer', label: OPS_KIND_LABEL.transfer, amount: opsSubAmount(rows, 'transfer') }, - ], - }, - ]; -} - -export function pendingAmount(rows: H2OrderRow[]): number { - return dimAmount(rows, 'pending'); -} - -export function unverified(rows: H2OrderRow[]): { amount: number; count: number } { - const list = rows.filter((r) => r.verifyStatus === 'unverified'); - return { amount: sumAmount(list), count: list.length }; -} - -export function formatYuan(n: number): string { - return `¥${n.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`; -} - -export function formatKg(n: number): string { - return `${n.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} kg`; -} - -export function costDimLabel(row: H2OrderRow): string { - if (row.costDim === 'lease' && row.leaseKind) { - return `${COST_DIM_LABEL.lease} · ${LEASE_KIND_LABEL[row.leaseKind]}`; - } - if (row.costDim === 'ops' && row.opsKind) { - return `${COST_DIM_LABEL.ops} · ${OPS_KIND_LABEL[row.opsKind]}`; - } - return COST_DIM_LABEL[row.costDim]; -} - -export type DimFilter = - | { dim: CostDim; sub?: string } - | null; - -export function applyDimFilter(rows: H2OrderRow[], filter: DimFilter): H2OrderRow[] { - if (!filter) return rows; - return rows.filter((r) => { - if (r.borneBy !== 'company') return false; - if (r.costDim !== filter.dim) return false; - if (!filter.sub) return true; - if (filter.dim === 'lease') return r.leaseKind === filter.sub; - if (filter.dim === 'ops') return r.opsKind === filter.sub; - return true; - }); -} - -/** 统计/明细共用:维度筛后的我司成本行;无维度筛则全部我司行 */ -export function companyRowsForStats(rows: H2OrderRow[], filter: DimFilter): H2OrderRow[] { - if (!filter) return companyCostRows(rows); - return applyDimFilter(rows, filter); -} - -export interface StationMonthRow { - stationId: string; - stationName: string; - month: string; - amount: number; - quantityKg: number; - unverifiedAmount: number; -} - -export function stationMonthAgg(rows: H2OrderRow[]): StationMonthRow[] { - const map = new Map(); - rows.forEach((r) => { - const month = r.occurredAt.slice(0, 7); - const key = `${r.stationId}|${month}`; - const list = map.get(key) ?? []; - list.push(r); - map.set(key, list); - }); - return Array.from(map.entries()) - .map(([key, list]) => { - const [stationId, month] = key.split('|'); - return { - stationId, - stationName: list[0].stationName, - month, - amount: sumAmount(list), - quantityKg: sumKg(list), - unverifiedAmount: sumAmount(list.filter((x) => x.verifyStatus === 'unverified')), - }; - }) - .sort((a, b) => b.amount - a.amount); -} - -export interface CustomerAttrRow { - customerId: string; - customerName: string; - borneLabel: string; - quantityKg: number; - companyCost: number; - unverifiedAmount: number; -} - -export function customerAttrAgg(rows: H2OrderRow[]): CustomerAttrRow[] { - const map = new Map(); - rows.forEach((r) => { - const list = map.get(r.customerId) ?? []; - list.push(r); - map.set(r.customerId, list); - }); - return Array.from(map.entries()) - .map(([customerId, list]) => { - const company = list.filter((x) => x.borneBy === 'company'); - const customer = list.filter((x) => x.borneBy === 'customer'); - let borneLabel = '混合'; - if (company.length && !customer.length) borneLabel = '我司'; - else if (customer.length && !company.length) borneLabel = '客户'; - return { - customerId, - customerName: list[0].customerName, - borneLabel, - quantityKg: sumKg(list), - companyCost: sumAmount(company), - unverifiedAmount: sumAmount(list.filter((x) => x.verifyStatus === 'unverified')), - }; - }) - .sort((a, b) => b.companyCost - a.companyCost || b.quantityKg - a.quantityKg); -} - -export const SOURCE_LABEL: Record = { - api: 'API', - manual: '补录', - fence: '围栏', -}; - -/** 总览 KPI:在宿主示意量级上按当前筛选(年/车辆/核对)等比缩放,保证卡片跟随顶栏筛选 */ -export function computeHostKpi( - filtered: H2OrderRow[], - year: number, - allOrders: H2OrderRow[], - base: { - totalKgT: number; - companyKgT: number; - customerKgT: number; - totalFeeWan: number; - companyFeeWan: number; - customerFeeWan: number; - profitWan: number; - incomeWan: number; - costWan: number; - monthKgT: number; - monthFeeWan: number; - monthYearPct: number; - dayKg: number; - dayFee: number; - dayMonthPct: number; - }, -) { - const round2 = (n: number) => Math.round(n * 100) / 100; - const baseline = filterOrders(allOrders, year, 'all', 'all'); - const baseKg = sumKg(baseline) || 1; - const fKg = sumKg(filtered); - const ratio = fKg / baseKg; - - const companyKg = sumKg(filtered.filter((r) => r.borneBy === 'company')); - const customerKg = sumKg(filtered.filter((r) => r.borneBy === 'customer')); - const split = companyKg + customerKg || 1; - const companyShare = companyKg / split; - const customerShare = customerKg / split; - - const monthRows = filtered.filter((r) => r.occurredAt.startsWith(`${year}-08`)); - const dayRows = filtered.filter((r) => r.occurredAt.startsWith(`${year}-08-08`)); - const monthKg = sumKg(monthRows); - const dayKgVal = sumKg(dayRows); - const monthAmt = sumAmount(monthRows); - const dayAmt = sumAmount(dayRows); - const yearKg = fKg || 1; - const monthKgShare = monthKg / yearKg; - const dayMonthShare = monthKg > 0 ? dayKgVal / monthKg : 0; - - const totalKgT = round2(base.totalKgT * ratio); - const totalFeeWan = round2(base.totalFeeWan * ratio); - const incomeWan = round2(base.incomeWan * ratio); - const costWan = round2(base.costWan * ratio); - const profitWan = round2(base.profitWan * ratio); - const monthKgT = round2(totalKgT * monthKgShare); - const monthFeeWan = round2(totalFeeWan * monthKgShare); - - return { - totalKgT, - companyKgT: round2(totalKgT * companyShare), - customerKgT: round2(totalKgT * customerShare), - totalFeeWan, - companyFeeWan: round2(totalFeeWan * companyShare), - customerFeeWan: round2(totalFeeWan * customerShare), - profitWan, - incomeWan, - costWan, - monthKgT, - monthFeeWan, - monthYearPct: round2(monthKgShare * 100), - dayKg: round2(dayKgVal > 0 ? dayKgVal : base.dayKg * ratio * Math.max(dayMonthShare, 0.01)), - dayFee: Math.round(dayAmt > 0 ? dayAmt : base.dayFee * ratio * Math.max(dayMonthShare, 0.01)), - dayMonthPct: round2((dayMonthShare || base.dayMonthPct / 100) * 100), - profitRatePct: incomeWan > 0 ? round2((profitWan / incomeWan) * 100) : 0, - }; -} diff --git a/src/modules/energy/hydrogen-bi-v2/prototype-source/data/mockBoard.ts b/src/modules/energy/hydrogen-bi-v2/prototype-source/data/mockBoard.ts deleted file mode 100644 index 00b01bc..0000000 --- a/src/modules/energy/hydrogen-bi-v2/prototype-source/data/mockBoard.ts +++ /dev/null @@ -1,182 +0,0 @@ -import type { H2OrderRow, StationPrepaid } from '../types'; - -/** 辅助生成 200 条逼真高质量订单明细假数据 */ -function generate200MockOrders(): H2OrderRow[] { - const stations = [ - { id: 'st-ln', name: '佛山南海羚牛加氢站' }, - { id: 'st-dp', name: '东鹏大道甲醇制氢一体站' }, - { id: 'st-jx', name: '嘉兴中石化滨海加氢站' }, - { id: 'st-jj', name: '嘉兴嘉锦加氢站' }, - { id: 'st-cd', name: '成都中石化天府机场高速北站加氢站' }, - { id: 'st-gz', name: '广州黄埔高新区氢能示范加氢站' }, - { id: 'st-sh', name: '上海安亭加氢站' }, - ]; - - const ownPlates = [ - '粤A99887', '粤B12001', '浙A52088', '浙F77881', '浙A88888F', - '浙A66666', '浙F11223', '浙F33445', '川A77889', '川B99001', - '沪A33219', '粤B88102', '浙F99812', '浙F66521', '粤A11029', - ]; - - const extPlates = [ - '粤A77661', '浙F33211', '川A55432', '沪B98765', '粤B66554', - '浙A22334', '粤A99102', '川B88761', '无车牌(散车)', - ]; - - const customers = [ - { id: 'c-ln', name: '羚牛自营', type: 'internal', deptId: 'd-ops', deptName: '运维中心' }, - { id: 'c-bao', name: '包氢专线项目', type: 'internal', deptId: 'd-lease', deptName: '租赁业务一部' }, - { id: 'c-lease-a', name: '嘉兴智奇供应链', type: 'internal', deptId: 'd-lease', deptName: '租赁业务一部' }, - { id: 'c-log', name: '嘉兴益顺冷链', type: 'internal', deptId: 'd-log', deptName: '物流中心' }, - { id: 'c-log2', name: '四川群彬物流', type: 'internal', deptId: 'd-log', deptName: '物流中心' }, - { id: 'c-lease-b', name: '无锡铭康物流', type: 'internal', deptId: 'd-lease', deptName: '租赁业务二部' }, - { id: 'c-ops', name: '运维调拨车辆', type: 'internal', deptId: 'd-ops', deptName: '运维中心' }, - { id: 'c-pend', name: '待归属样本', type: 'internal', deptId: 'd-lease', deptName: '租赁业务一部' }, - { id: 'c-ext-a', name: '广东氢动力', type: 'external', deptId: 'd-sales', deptName: '能源销售' }, - { id: 'c-ext-b', name: '广东清运专线', type: 'external', deptId: 'd-sales', deptName: '能源销售' }, - { id: 'c-ext-c', name: '东展供应链', type: 'external', deptId: 'd-sales', deptName: '能源销售' }, - { id: 'c-ext-d', name: '顺丰冷运专线', type: 'external', deptId: 'd-sales', deptName: '能源销售' }, - { id: 'c-ext-e', name: '极兔速递冷链', type: 'external', deptId: 'd-sales', deptName: '能源销售' }, - ]; - - const list: H2OrderRow[] = []; - - // 生成 200 条记录 - for (let i = 1; i <= 200; i++) { - const padId = String(i).padStart(3, '0'); - - // 年份分配: 1~145 (2026年), 146~185 (2025年), 186~200 (2024年) - let year = 2026; - let month = Math.floor((i % 8)) + 1; // 1~8月 - if (i > 145 && i <= 185) { - year = 2025; - month = Math.floor((i % 12)) + 1; - } else if (i > 185) { - year = 2024; - month = Math.floor((i % 12)) + 1; - } - - const day = (i * 7 % 28) + 1; - const hour = (i * 3 % 14) + 7; - const minute = (i * 11 % 50) + 5; - - const mm = month < 10 ? `0${month}` : `${month}`; - const dd = day < 10 ? `0${day}` : `${day}`; - const hh = hour < 10 ? `0${hour}` : `${hour}`; - const min = minute < 10 ? `0${minute}` : `${minute}`; - - const occurredAt = `${year}-${mm}-${dd} ${hh}:${min}`; - const station = stations[i % stations.length]; - const customer = customers[i % customers.length]; - - const isOwn = customer.type === 'internal'; - const fleet = isOwn ? 'own' : 'external'; - const plateNo = isOwn - ? ownPlates[i % ownPlates.length] - : extPlates[i % extPlates.length]; - - // 单价 & 加氢量 - const unitPrice = [28, 30, 32, 35][i % 4]; - const quantityKg = Math.round((18 + (i * 3.7 % 85)) * 100) / 100; - const amount = Math.round(quantityKg * unitPrice); - - // 成本维度与费用承担 - let borneBy: 'company' | 'customer' = 'company'; - let costDim: 'lease' | 'logistics' | 'ops' | 'pending' = 'lease'; - let leaseKind: 'company_borne' | 'package_h2' | undefined = undefined; - let opsKind: 'abnormal' | 'transfer' | undefined = undefined; - - if (customer.id === 'c-ext-a' || customer.id === 'c-ext-d') { - borneBy = 'customer'; - } - - const dimType = i % 5; - if (dimType === 0) { - costDim = 'lease'; - leaseKind = 'company_borne'; - } else if (dimType === 1) { - costDim = 'lease'; - leaseKind = 'package_h2'; - } else if (dimType === 2) { - costDim = 'logistics'; - } else if (dimType === 3) { - costDim = 'ops'; - opsKind = i % 2 === 0 ? 'abnormal' : 'transfer'; - } else { - costDim = 'pending'; - } - - // 核对状态与数据来源 - const verifyStatus = isOwn ? (i % 4 === 0 ? 'unverified' : 'verified') : 'unverified'; - const source = isOwn - ? (i % 3 === 0 ? 'manual' : i % 3 === 1 ? 'fence' : 'api') - : (i % 2 === 0 ? 'api' : 'manual'); - - list.push({ - id: `HO-${String(year).slice(2)}${mm}-${padId}`, - occurredAt, - stationId: station.id, - stationName: station.name, - plateNo, - customerId: customer.id, - customerName: customer.name, - deptId: customer.deptId, - deptName: customer.deptName, - amount, - quantityKg, - unitPrice, - borneBy, - costDim, - leaseKind, - opsKind, - verifyStatus, - source, - fleet, - }); - } - - return list; -} - -/** 假数:200 条订单明细(三维度成本拆分) */ -export const MOCK_ORDERS: H2OrderRow[] = generate200MockOrders(); - -export const MOCK_PREPAID: StationPrepaid[] = [ - { - stationId: 'st-jx', - stationName: '嘉兴中石化滨海加氢站', - openingBalance: 120000, - openingAnchorLabel: '2025 年末财务期末', - recharge: 80000, - consume: 95000, - }, - { - stationId: 'st-jj', - stationName: '嘉兴嘉锦加氢站', - openingBalance: null, - openingAnchorLabel: null, - recharge: 40000, - consume: 28000, - }, -]; - -/** 宿主总览 KPI(与 zip content 同量级示意,只读壳) */ -export const HOST_KPI = { - totalKgT: 697.16, - companyKgT: 468.28, - customerKgT: 228.88, - totalFeeWan: 2093.71, - companyFeeWan: 1362.85, - customerFeeWan: 730.87, - profitWan: 13.01, - incomeWan: 743.88, - costWan: 2093.71, - monthKgT: 16.67, - monthFeeWan: 50.36, - monthYearPct: 2.4, - dayKg: 181.78, - dayFee: 6533, - dayMonthPct: 1.1, -}; - -export const DEFAULT_YEAR = 2026; diff --git a/src/modules/energy/hydrogen-bi-v2/prototype-source/data/mockDaily.ts b/src/modules/energy/hydrogen-bi-v2/prototype-source/data/mockDaily.ts deleted file mode 100644 index a7d7cfd..0000000 --- a/src/modules/energy/hydrogen-bi-v2/prototype-source/data/mockDaily.ts +++ /dev/null @@ -1,1044 +0,0 @@ -/** 宿主按日视图 (Daily View) 模拟数据 - 包含加氢站、客户、车辆及数据来源穿透明细 - * - * 外部车牌主数据对齐:`src/common/energy-h2-external-fleet` 种子 - * (粤B12345D / 粤A99888D / 粤E88111D / 粤B99111D / 粤B33888D / 粤E99999D)。 - * 分流规则:own → 车辆氢费明细;external → 仅 BI(见 resolveFleetRoute)。 - */ - -export type StationType = 'self_use' | 'external_sale'; // 自用消费 | 对外销售 -export type SourceType = 'api' | 'station_report' | 'lingniu_report'; // API接入 | 站点上报 | 羚牛上报 -export type FleetCategory = 'own' | 'external'; // 羚牛车辆 | 外部车辆 -export type FleetCategoryFilter = 'all' | 'own' | 'external'; // 全量合并 | 羚牛车辆 | 外部车辆 -export type CustomerCategory = 'internal' | 'external'; // 内部客户 | 外部客户 -export type DailyVerifyStatus = 'verified' | 'unverified'; // 已核对 | 未核对 - -export const SOURCE_TYPE_LABEL: Record = { - api: 'API接入', - station_report: '站点上报', - lingniu_report: '羚牛上报', -}; - -export const STATION_TYPE_LABEL: Record = { - self_use: '自用消费', - external_sale: '对外销售', -}; - -export const CUSTOMER_CATEGORY_LABEL: Record = { - internal: '内部客户', - external: '外部客户', -}; - -export const DAILY_VERIFY_LABEL: Record = { - verified: '已核对', - unverified: '未核对', -}; - -export interface VehicleRefDetail { - id: string; - time: string; - plateNo: string | null; // 车牌号(内部车必有,外部车可能无) - fleetCategory: FleetCategory; // 羚牛车辆(内部) | 外部车辆 - quantityKg: number; - unitPrice: number; - amountYuan: number; - source: SourceType; // 内部: API接入/站点上报/羚牛上报;外部: 仅API接入/站点上报 - verifyStatus?: DailyVerifyStatus | null; // 内部车辆必有(已核对/未核对);外部车辆无核对状态 -} - -export interface CustomerDetail { - customerId: string; - customerName: string; - customerCategory?: CustomerCategory; // 内部客户 | 外部客户 - quantityKg: number; - amountYuan: number; - vehicles: VehicleRefDetail[]; -} - -export interface DailyStationDetail { - stationId: string; - stationName: string; - stationType: StationType; // 自用消费 | 对外销售 - unitPrice: number; - quantityKg: number; - amountYuan: number; - prechargeBalance?: number; // 对接站点管理预充值余额 - customers: CustomerDetail[]; -} - -export interface DailyItem { - date: string; // YYYY-MM-DD - shortDate: string; // MM-DD - unitPrice: number; - quantityKg: number; - amountYuan: number; - momPct: number | null; // 环比 - stations: DailyStationDetail[]; -} - -export const MOCK_DAILY_15DAYS: DailyItem[] = [ - { - date: '2026-08-08', - shortDate: '08-08', - unitPrice: 30, - quantityKg: 3139.12, - amountYuan: 94173.6, - momPct: 1626.9, - stations: [ - { - stationId: 'st-jx', - stationName: '嘉兴中石化滨海加氢站', - stationType: 'self_use', - unitPrice: 30, - quantityKg: 1800.0, - amountYuan: 54000.0, - customers: [ - { - customerId: 'c-ln', - customerName: '羚牛氢能科技(广东)有限公司', - quantityKg: 1100.0, - amountYuan: 33000.0, - vehicles: [ - { id: 'v-101', time: '08:15', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 45.2, unitPrice: 30, amountYuan: 1356, source: 'api', verifyStatus: 'verified' }, - { id: 'v-102', time: '09:30', plateNo: '浙A66666F', fleetCategory: 'own', quantityKg: 54.8, unitPrice: 30, amountYuan: 1644, source: 'lingniu_report', verifyStatus: 'verified' }, - { id: 'v-103', time: '11:20', plateNo: '浙A33333F', fleetCategory: 'own', quantityKg: 40.0, unitPrice: 30, amountYuan: 1200, source: 'station_report', verifyStatus: 'unverified' }, - { id: 'v-104', time: '14:00', plateNo: '浙A99999F', fleetCategory: 'own', quantityKg: 960.0, unitPrice: 30, amountYuan: 28800, source: 'api', verifyStatus: 'verified' }, - ], - }, - { - customerId: 'c-qd', - customerName: '广东氢动力科技服务有限公司', - quantityKg: 700.0, - amountYuan: 21000.0, - vehicles: [ - { id: 'v-201', time: '10:10', plateNo: '粤B12345D', fleetCategory: 'external', quantityKg: 350.0, unitPrice: 30, amountYuan: 10500, source: 'station_report', verifyStatus: null }, - { id: 'v-202', time: '15:45', plateNo: null, fleetCategory: 'external', quantityKg: 350.0, unitPrice: 30, amountYuan: 10500, source: 'api', verifyStatus: null }, - ], - }, - ], - }, - { - stationId: 'st-fs', - stationName: '佛山南海加氢站', - stationType: 'external_sale', - unitPrice: 38, - quantityKg: 1339.12, - amountYuan: 50886.56, - customers: [ - { - customerId: 'c-ln', - customerName: '羚牛氢能科技(广东)有限公司', - quantityKg: 500.0, - amountYuan: 19000.0, - vehicles: [ - { id: 'v-300', time: '06:50', plateNo: '粤B88666D', fleetCategory: 'own', quantityKg: 500.0, unitPrice: 38, amountYuan: 19000, source: 'api', verifyStatus: 'verified' }, - ], - }, - { - customerId: 'c-qy', - customerName: '广东清运科技有限公司', - quantityKg: 300.0, - amountYuan: 11400.0, - vehicles: [ - { id: 'v-301', time: '07:40', plateNo: '粤E88111D', fleetCategory: 'external', quantityKg: 300.0, unitPrice: 38, amountYuan: 11400, source: 'station_report', verifyStatus: null }, - ], - }, - { - customerId: 'c-dp', - customerName: '东展供应链(广州)有限公司', - quantityKg: 539.12, - amountYuan: 20486.56, - vehicles: [ - { id: 'v-401', time: '16:00', plateNo: '粤A99888D', fleetCategory: 'external', quantityKg: 539.12, unitPrice: 38, amountYuan: 20486.56, source: 'api', verifyStatus: null }, - ], - }, - ], - }, - ], - }, - { - date: '2026-08-07', - shortDate: '08-07', - unitPrice: 30, - quantityKg: 1181.78, - amountYuan: 35453.4, - momPct: -62.3, - stations: [ - { - stationId: 'st-jx', - stationName: '嘉兴中石化滨海加氢站', - stationType: 'self_use', - unitPrice: 30, - quantityKg: 681.78, - amountYuan: 20453.4, - customers: [ - { - customerId: 'c-ln', - customerName: '羚牛氢能科技(广东)有限公司', - quantityKg: 181.78, - amountYuan: 5453.4, - vehicles: [ - { id: 'v-501', time: '09:00', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 81.78, unitPrice: 30, amountYuan: 2453.4, source: 'lingniu_report', verifyStatus: 'verified' }, - { id: 'v-502', time: '14:30', plateNo: '浙A66666F', fleetCategory: 'own', quantityKg: 100.0, unitPrice: 30, amountYuan: 3000.0, source: 'api', verifyStatus: 'verified' }, - ], - }, - { - customerId: 'c-qy', - customerName: '广东清运科技有限公司', - quantityKg: 500.0, - amountYuan: 15000.0, - vehicles: [ - { id: 'v-503', time: '16:10', plateNo: null, fleetCategory: 'external', quantityKg: 500.0, unitPrice: 30, amountYuan: 15000, source: 'station_report', verifyStatus: null }, - ], - }, - ], - }, - { - stationId: 'st-jj', - stationName: '嘉兴嘉锦加氢站', - stationType: 'self_use', - unitPrice: 30, - quantityKg: 500.0, - amountYuan: 15000.0, - customers: [ - { - customerId: 'c-qd', - customerName: '广东氢动力科技服务有限公司', - quantityKg: 500.0, - amountYuan: 15000.0, - vehicles: [ - { id: 'v-504', time: '11:00', plateNo: '粤B99111D', fleetCategory: 'external', quantityKg: 500.0, unitPrice: 30, amountYuan: 15000, source: 'api', verifyStatus: null }, - ], - }, - ], - }, - ], - }, - { - date: '2026-08-06', - shortDate: '08-06', - unitPrice: 30, - quantityKg: 2250.0, - amountYuan: 67500.0, - momPct: 90.4, - stations: [ - { - stationId: 'st-jx', - stationName: '嘉兴中石化滨海加氢站', - stationType: 'self_use', - unitPrice: 30, - quantityKg: 1250.0, - amountYuan: 37500.0, - customers: [ - { - customerId: 'c-ln', - customerName: '羚牛氢能科技(广东)有限公司', - quantityKg: 850.0, - amountYuan: 25500.0, - vehicles: [ - { id: 'v-601', time: '10:00', plateNo: '浙A55555F', fleetCategory: 'own', quantityKg: 450.0, unitPrice: 30, amountYuan: 13500, source: 'api', verifyStatus: 'verified' }, - { id: 'v-602', time: '16:00', plateNo: '浙A33333F', fleetCategory: 'own', quantityKg: 400.0, unitPrice: 30, amountYuan: 12000, source: 'lingniu_report', verifyStatus: 'unverified' }, - ], - }, - { - customerId: 'c-qd', - customerName: '广东氢动力科技服务有限公司', - quantityKg: 400.0, - amountYuan: 12000.0, - vehicles: [ - { id: 'v-603', time: '17:30', plateNo: '粤B33888D', fleetCategory: 'external', quantityKg: 400.0, unitPrice: 30, amountYuan: 12000, source: 'station_report', verifyStatus: null }, - ], - }, - ], - }, - { - stationId: 'st-dp', - stationName: '东鹏加氢站(对外)', - stationType: 'external_sale', - unitPrice: 38, - quantityKg: 1000.0, - amountYuan: 38000.0, - customers: [ - { - customerId: 'c-ln', - customerName: '羚牛氢能科技(广东)有限公司', - quantityKg: 300.0, - amountYuan: 11400.0, - vehicles: [ - { id: 'v-700', time: '09:10', plateNo: '浙A11222F', fleetCategory: 'own', quantityKg: 300.0, unitPrice: 38, amountYuan: 11400, source: 'api', verifyStatus: 'verified' }, - ], - }, - { - customerId: 'c-dp', - customerName: '东展供应链(广州)有限公司', - quantityKg: 700.0, - amountYuan: 26600.0, - vehicles: [ - { id: 'v-701', time: '11:30', plateNo: null, fleetCategory: 'external', quantityKg: 700.0, unitPrice: 38, amountYuan: 26600, source: 'station_report', verifyStatus: null }, - ], - }, - ], - }, - ], - }, - { - date: '2026-08-05', - shortDate: '08-05', - unitPrice: 30, - quantityKg: 2720.0, - amountYuan: 81600.0, - momPct: 20.9, - stations: [ - { - stationId: 'st-jj', - stationName: '嘉兴嘉锦加氢站', - stationType: 'self_use', - unitPrice: 30, - quantityKg: 2720.0, - amountYuan: 81600.0, - customers: [ - { - customerId: 'c-ln', - customerName: '羚牛氢能科技(广东)有限公司', - quantityKg: 1720.0, - amountYuan: 51600.0, - vehicles: [ - { id: 'v-801', time: '08:00', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 860.0, unitPrice: 30, amountYuan: 25800, source: 'api', verifyStatus: 'verified' }, - { id: 'v-802', time: '15:20', plateNo: '浙A99999F', fleetCategory: 'own', quantityKg: 860.0, unitPrice: 30, amountYuan: 25800, source: 'lingniu_report', verifyStatus: 'verified' }, - ], - }, - { - customerId: 'c-qy', - customerName: '广东清运科技有限公司', - quantityKg: 1000.0, - amountYuan: 30000.0, - vehicles: [ - { id: 'v-803', time: '18:00', plateNo: '粤E99999D', fleetCategory: 'external', quantityKg: 1000.0, unitPrice: 30, amountYuan: 30000, source: 'api', verifyStatus: null }, - ], - }, - ], - }, - ], - }, - { - date: '2026-08-04', - shortDate: '08-04', - unitPrice: 30, - quantityKg: 3150.0, - amountYuan: 94500.0, - momPct: 1.6, - stations: [ - { - stationId: 'st-jx', - stationName: '嘉兴中石化滨海加氢站', - stationType: 'self_use', - unitPrice: 30, - quantityKg: 3150.0, - amountYuan: 94500.0, - customers: [ - { - customerId: 'c-ln', - customerName: '羚牛氢能科技(广东)有限公司', - quantityKg: 1000.0, - amountYuan: 30000.0, - vehicles: [ - { id: 'v-900', time: '07:30', plateNo: '浙A77777F', fleetCategory: 'own', quantityKg: 1000.0, unitPrice: 30, amountYuan: 30000, source: 'api', verifyStatus: 'verified' }, - ], - }, - { - customerId: 'c-qd', - customerName: '广东氢动力科技服务有限公司', - quantityKg: 2150.0, - amountYuan: 64500.0, - vehicles: [ - { id: 'v-901', time: '09:10', plateNo: '粤B88888D', fleetCategory: 'external', quantityKg: 1075.0, unitPrice: 30, amountYuan: 32250, source: 'station_report', verifyStatus: null }, - { id: 'v-902', time: '16:40', plateNo: '粤B99999D', fleetCategory: 'external', quantityKg: 1075.0, unitPrice: 30, amountYuan: 32250, source: 'api', verifyStatus: null }, - ], - }, - ], - }, - ], - }, - { - date: '2026-08-03', - shortDate: '08-03', - unitPrice: 30, - quantityKg: 3100.0, - amountYuan: 93000.0, - momPct: 5.1, - stations: [ - { - stationId: 'st-sh', - stationName: '上海金山加氢站', - stationType: 'self_use', - unitPrice: 30, - quantityKg: 3100.0, - amountYuan: 93000.0, - customers: [ - { - customerId: 'c-ln', - customerName: '羚牛氢能科技(广东)有限公司', - quantityKg: 2100.0, - amountYuan: 63000.0, - vehicles: [ - { id: 'v-1001', time: '10:30', plateNo: '沪A88111F', fleetCategory: 'own', quantityKg: 2100.0, unitPrice: 30, amountYuan: 63000, source: 'api', verifyStatus: 'verified' }, - ], - }, - { - customerId: 'c-dp', - customerName: '东展供应链(广州)有限公司', - quantityKg: 1000.0, - amountYuan: 30000.0, - vehicles: [ - { id: 'v-1002', time: '15:10', plateNo: null, fleetCategory: 'external', quantityKg: 1000.0, unitPrice: 30, amountYuan: 30000, source: 'station_report', verifyStatus: null }, - ], - }, - ], - }, - ], - }, - { - date: '2026-08-02', - shortDate: '08-02', - unitPrice: 30, - quantityKg: 2950.0, - amountYuan: 88500.0, - momPct: -18.5, - stations: [ - { - stationId: 'st-jx', - stationName: '嘉兴中石化滨海加氢站', - stationType: 'self_use', - unitPrice: 30, - quantityKg: 2950.0, - amountYuan: 88500.0, - customers: [ - { - customerId: 'c-ln', - customerName: '羚牛氢能科技(广东)有限公司', - quantityKg: 2000.0, - amountYuan: 60000.0, - vehicles: [ - { id: 'v-1101', time: '08:45', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 1000.0, unitPrice: 30, amountYuan: 30000, source: 'lingniu_report', verifyStatus: 'verified' }, - { id: 'v-1102', time: '14:15', plateNo: '浙A66666F', fleetCategory: 'own', quantityKg: 1000.0, unitPrice: 30, amountYuan: 30000, source: 'api', verifyStatus: 'verified' }, - ], - }, - { - customerId: 'c-qy', - customerName: '广东清运科技有限公司', - quantityKg: 950.0, - amountYuan: 28500.0, - vehicles: [ - { id: 'v-1103', time: '17:00', plateNo: null, fleetCategory: 'external', quantityKg: 950.0, unitPrice: 30, amountYuan: 28500, source: 'station_report', verifyStatus: null }, - ], - }, - ], - }, - ], - }, - { - date: '2026-08-01', - shortDate: '08-01', - unitPrice: 30, - quantityKg: 3620.0, - amountYuan: 108600.0, - momPct: 1.1, - stations: [ - { - stationId: 'st-jx', - stationName: '嘉兴中石化滨海加氢站', - stationType: 'self_use', - unitPrice: 30, - quantityKg: 2000.0, - amountYuan: 60000.0, - customers: [ - { - customerId: 'c-ln', - customerName: '羚牛氢能科技(广东)有限公司', - quantityKg: 2000.0, - amountYuan: 60000.0, - vehicles: [ - { id: 'v-1201', time: '09:00', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 2000.0, unitPrice: 30, amountYuan: 60000, source: 'api', verifyStatus: 'verified' }, - ], - }, - ], - }, - { - stationId: 'st-jj', - stationName: '嘉兴嘉锦加氢站', - stationType: 'self_use', - unitPrice: 30, - quantityKg: 1620.0, - amountYuan: 48600.0, - customers: [ - { - customerId: 'c-qd', - customerName: '广东氢动力科技服务有限公司', - quantityKg: 1620.0, - amountYuan: 48600.0, - vehicles: [ - { id: 'v-1202', time: '11:20', plateNo: '粤B33333D', fleetCategory: 'external', quantityKg: 1620.0, unitPrice: 30, amountYuan: 48600, source: 'station_report', verifyStatus: null }, - ], - }, - ], - }, - ], - }, - { - date: '2026-07-31', - shortDate: '07-31', - unitPrice: 30, - quantityKg: 3580.0, - amountYuan: 107400.0, - momPct: -4.5, - stations: [ - { - stationId: 'st-jj', - stationName: '嘉兴嘉锦加氢站', - stationType: 'self_use', - unitPrice: 30, - quantityKg: 3580.0, - amountYuan: 107400.0, - customers: [ - { - customerId: 'c-ln', - customerName: '羚牛氢能科技(广东)有限公司', - quantityKg: 2580.0, - amountYuan: 77400.0, - vehicles: [ - { id: 'v-1301', time: '10:00', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 2580.0, unitPrice: 30, amountYuan: 77400, source: 'api', verifyStatus: 'verified' }, - ], - }, - { - customerId: 'c-qd', - customerName: '广东氢动力科技服务有限公司', - quantityKg: 1000.0, - amountYuan: 30000.0, - vehicles: [ - { id: 'v-1302', time: '16:00', plateNo: '粤B55555D', fleetCategory: 'external', quantityKg: 1000.0, unitPrice: 30, amountYuan: 30000, source: 'api', verifyStatus: null }, - ], - }, - ], - }, - ], - }, - { - date: '2026-07-30', - shortDate: '07-30', - unitPrice: 30, - quantityKg: 3750.0, - amountYuan: 112500.0, - momPct: 8.7, - stations: [ - { - stationId: 'st-jx', - stationName: '嘉兴中石化滨海加氢站', - stationType: 'self_use', - unitPrice: 30, - quantityKg: 3750.0, - amountYuan: 112500.0, - customers: [ - { - customerId: 'c-ln', - customerName: '羚牛氢能科技(广东)有限公司', - quantityKg: 3750.0, - amountYuan: 112500.0, - vehicles: [ - { id: 'v-1401', time: '08:30', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 3750.0, unitPrice: 30, amountYuan: 112500, source: 'lingniu_report', verifyStatus: 'verified' }, - ], - }, - ], - }, - ], - }, - { - date: '2026-07-29', - shortDate: '07-29', - unitPrice: 30, - quantityKg: 3450.0, - amountYuan: 103500.0, - momPct: 15.8, - stations: [ - { - stationId: 'st-hz', - stationName: '杭州临安加氢站', - stationType: 'self_use', - unitPrice: 30, - quantityKg: 3450.0, - amountYuan: 103500.0, - customers: [ - { - customerId: 'c-ln', - customerName: '羚牛氢能科技(广东)有限公司', - quantityKg: 2450.0, - amountYuan: 73500.0, - vehicles: [ - { id: 'v-1501', time: '13:00', plateNo: '浙A66666F', fleetCategory: 'own', quantityKg: 2450.0, unitPrice: 30, amountYuan: 73500, source: 'api', verifyStatus: 'verified' }, - ], - }, - { - customerId: 'c-qy', - customerName: '广东清运科技有限公司', - quantityKg: 1000.0, - amountYuan: 30000.0, - vehicles: [ - { id: 'v-1502', time: '17:40', plateNo: '浙A99111D', fleetCategory: 'external', quantityKg: 1000.0, unitPrice: 30, amountYuan: 30000, source: 'station_report', verifyStatus: null }, - ], - }, - ], - }, - ], - }, - { - date: '2026-07-28', - shortDate: '07-28', - unitPrice: 30, - quantityKg: 2980.2, - amountYuan: 89406.0, - momPct: -4.5, - stations: [ - { - stationId: 'st-jx', - stationName: '嘉兴中石化滨海加氢站', - stationType: 'self_use', - unitPrice: 30, - quantityKg: 2980.2, - amountYuan: 89406.0, - customers: [ - { - customerId: 'c-ln', - customerName: '羚牛氢能科技(广东)有限公司', - quantityKg: 2980.2, - amountYuan: 89406.0, - vehicles: [ - { id: 'v-1601', time: '15:10', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 2980.2, unitPrice: 30, amountYuan: 89406, source: 'lingniu_report', verifyStatus: 'verified' }, - ], - }, - ], - }, - ], - }, - { - date: '2026-07-27', - shortDate: '07-27', - unitPrice: 30, - quantityKg: 3120.0, - amountYuan: 93600.0, - momPct: 33.3, - stations: [ - { - stationId: 'st-sh', - stationName: '上海金山加氢站', - stationType: 'self_use', - unitPrice: 30, - quantityKg: 3120.0, - amountYuan: 93600.0, - customers: [ - { - customerId: 'c-ln', - customerName: '羚牛氢能科技(广东)有限公司', - quantityKg: 2120.0, - amountYuan: 63600.0, - vehicles: [ - { id: 'v-1701', time: '11:00', plateNo: '沪A88111F', fleetCategory: 'own', quantityKg: 2120.0, unitPrice: 30, amountYuan: 63600, source: 'api', verifyStatus: 'verified' }, - ], - }, - { - customerId: 'c-dp', - customerName: '东展供应链(广州)有限公司', - quantityKg: 1000.0, - amountYuan: 30000.0, - vehicles: [ - { id: 'v-1702', time: '16:20', plateNo: null, fleetCategory: 'external', quantityKg: 1000.0, unitPrice: 30, amountYuan: 30000, source: 'station_report', verifyStatus: null }, - ], - }, - ], - }, - ], - }, - { - date: '2026-07-26', - shortDate: '07-26', - unitPrice: 30, - quantityKg: 2340.5, - amountYuan: 70215.0, - momPct: -36.5, - stations: [ - { - stationId: 'st-jx', - stationName: '嘉兴中石化滨海加氢站', - stationType: 'self_use', - unitPrice: 30, - quantityKg: 2340.5, - amountYuan: 70215.0, - customers: [ - { - customerId: 'c-ln', - customerName: '羚牛氢能科技(广东)有限公司', - quantityKg: 2340.5, - amountYuan: 70215.0, - vehicles: [ - { id: 'v-1801', time: '14:20', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 2340.5, unitPrice: 30, amountYuan: 70215, source: 'api', verifyStatus: 'verified' }, - ], - }, - ], - }, - ], - }, - { - date: '2026-07-25', - shortDate: '07-25', - unitPrice: 30, - quantityKg: 3683.0, // 峰值 - amountYuan: 110490.0, - momPct: null, - stations: [ - { - stationId: 'st-jx', - stationName: '嘉兴中石化滨海加氢站', - stationType: 'self_use', - unitPrice: 30, - quantityKg: 2183.0, - amountYuan: 65490.0, - customers: [ - { - customerId: 'c-ln', - customerName: '羚牛氢能科技(广东)有限公司', - quantityKg: 2183.0, - amountYuan: 65490.0, - vehicles: [ - { id: 'v-1901', time: '09:30', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 2183.0, unitPrice: 30, amountYuan: 65490, source: 'api', verifyStatus: 'verified' }, - ], - }, - ], - }, - { - stationId: 'st-jj', - stationName: '嘉兴嘉锦加氢站', - stationType: 'self_use', - unitPrice: 30, - quantityKg: 1500.0, - amountYuan: 45000.0, - customers: [ - { - customerId: 'c-qd', - customerName: '广东氢动力科技服务有限公司', - quantityKg: 1500.0, - amountYuan: 45000.0, - vehicles: [ - { id: 'v-1902', time: '16:00', plateNo: '粤B12345D', fleetCategory: 'external', quantityKg: 1500.0, unitPrice: 30, amountYuan: 45000, source: 'station_report', verifyStatus: null }, - ], - }, - ], - }, - ], - }, -]; - -/** 根据任意规范化开始与结束日期 (YYYY-MM-DD) 动态生成完整区间内的 DailyItem 列表 */ -export function getDailyDataForRange(normStartStr: string, normEndStr: string): DailyItem[] { - if (!normStartStr || !normEndStr) return MOCK_DAILY_15DAYS; - - const startParts = normStartStr.split('-').map(Number); - const endParts = normEndStr.split('-').map(Number); - if (startParts.length !== 3 || endParts.length !== 3) { - return MOCK_DAILY_15DAYS; - } - - const start = new Date(startParts[0], startParts[1] - 1, startParts[2]); - const end = new Date(endParts[0], endParts[1] - 1, endParts[2]); - - if (isNaN(start.getTime()) || isNaN(end.getTime()) || start > end) { - return MOCK_DAILY_15DAYS; - } - - // 限制最大时间跨度 180 天,保证性能 - const diffTime = Math.abs(end.getTime() - start.getTime()); - const diffDays = Math.min(180, Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1); - - const existingMap = new Map(); - MOCK_DAILY_15DAYS.forEach((item) => existingMap.set(item.date, item)); - - const result: DailyItem[] = []; - - for (let i = 0; i < diffDays; i++) { - const d = new Date(end.getTime() - i * (1000 * 60 * 60 * 24)); - const yyyy = d.getFullYear(); - const mm = String(d.getMonth() + 1).padStart(2, '0'); - const dd = String(d.getDate()).padStart(2, '0'); - const dateStr = `${yyyy}-${mm}-${dd}`; - const shortDate = `${mm}-${dd}`; - - if (existingMap.has(dateStr)) { - result.push(existingMap.get(dateStr)!); - } else { - // 稳定拟真生成算法 - const seed = yyyy * 10000 + Number(mm) * 100 + Number(dd); - const baseVal = 2200 + (seed % 1400) + (seed % 9) * 60; - - const ownKg = Math.round(baseVal * 0.62 * 10) / 10; - const extKg = Math.round(baseVal * 0.38 * 10) / 10; - const totalKg = Math.round((ownKg + extKg) * 10) / 10; - - result.push({ - date: dateStr, - shortDate, - unitPrice: 30, - quantityKg: totalKg, - amountYuan: Math.round(totalKg * 30 * 10) / 10, - momPct: Math.round(((seed % 24) - 12) * 10) / 10, - stations: [ - { - stationId: `st-jx-${dateStr}`, - stationName: '嘉兴中石化滨海加氢站', - stationType: 'self_use', - unitPrice: 30, - quantityKg: ownKg, - amountYuan: Math.round(ownKg * 30), - prechargeBalance: 128500 + (seed % 4000), - customers: [ - { - customerId: 'c-ln', - customerName: '羚牛氢能科技(广东)有限公司', - customerCategory: 'internal', - quantityKg: ownKg, - amountYuan: Math.round(ownKg * 30), - vehicles: [ - { - id: `v-gen1-${dateStr}`, - time: '08:30', - plateNo: `浙A${(seed % 89999) + 10000}F`, - fleetCategory: 'own', - quantityKg: Math.round(ownKg * 0.65 * 10) / 10, - unitPrice: 30, - amountYuan: Math.round(ownKg * 0.65 * 30), - source: 'api', - verifyStatus: 'verified', - }, - { - id: `v-gen2-${dateStr}`, - time: '14:20', - plateNo: `浙A${(seed % 79999) + 10000}F`, - fleetCategory: 'own', - quantityKg: Math.round(ownKg * 0.35 * 10) / 10, - unitPrice: 30, - amountYuan: Math.round(ownKg * 0.35 * 30), - source: 'station_report', - verifyStatus: seed % 2 === 0 ? 'verified' : 'unverified', - }, - ], - }, - ], - }, - { - stationId: `st-fs-${dateStr}`, - stationName: '佛山南海加氢站', - stationType: 'external_sale', - unitPrice: 35, - quantityKg: extKg, - amountYuan: Math.round(extKg * 35), - prechargeBalance: 92000 + (seed % 3000), - customers: [ - { - customerId: 'c-qd', - customerName: '广东氢动力科技服务有限公司', - customerCategory: 'external', - quantityKg: extKg, - amountYuan: Math.round(extKg * 35), - vehicles: [ - { - id: `v-gen3-${dateStr}`, - time: '10:15', - plateNo: `粤B${(seed % 89999) + 10000}D`, - fleetCategory: 'external', - quantityKg: Math.round(extKg * 0.7 * 10) / 10, - unitPrice: 35, - amountYuan: Math.round(extKg * 0.7 * 35), - source: 'api', - verifyStatus: null, - }, - { - id: `v-gen4-${dateStr}`, - time: '16:40', - plateNo: null, - fleetCategory: 'external', - quantityKg: Math.round(extKg * 0.3 * 10) / 10, - unitPrice: 35, - amountYuan: Math.round(extKg * 0.3 * 35), - source: 'station_report', - verifyStatus: null, - }, - ], - }, - ], - }, - ], - }); - } - } - - return result; -} - -/** 根据车辆归属类型 ('all' | 'own' | 'external') 过滤并重新层层汇总 DailyItem 列表 */ -export function filterDailyDataByFleet( - items: DailyItem[], - fleetFilter: FleetCategoryFilter, -): DailyItem[] { - if (fleetFilter === 'all') { - return items; - } - - const result: DailyItem[] = []; - - for (const day of items) { - const newStations: DailyStationDetail[] = []; - - for (const st of day.stations) { - const newCustomers: CustomerDetail[] = []; - - for (const cust of st.customers) { - // 过滤出符合 fleetFilter 的车辆明细 - const filteredVehicles = (cust.vehicles || []).filter( - (v) => v.fleetCategory === fleetFilter, - ); - - if (filteredVehicles.length > 0) { - const custQty = filteredVehicles.reduce((sum, v) => sum + v.quantityKg, 0); - const custAmount = filteredVehicles.reduce((sum, v) => sum + v.amountYuan, 0); - - newCustomers.push({ - ...cust, - quantityKg: Math.round(custQty * 100) / 100, - amountYuan: Math.round(custAmount * 100) / 100, - vehicles: filteredVehicles, - }); - } - } - - if (newCustomers.length > 0) { - const stQty = newCustomers.reduce((sum, c) => sum + c.quantityKg, 0); - const stAmount = newCustomers.reduce((sum, c) => sum + c.amountYuan, 0); - - newStations.push({ - ...st, - quantityKg: Math.round(stQty * 100) / 100, - amountYuan: Math.round(stAmount * 100) / 100, - customers: newCustomers, - }); - } - } - - if (newStations.length > 0) { - const dayQty = newStations.reduce((sum, s) => sum + s.quantityKg, 0); - const dayAmount = newStations.reduce((sum, s) => sum + s.amountYuan, 0); - - result.push({ - ...day, - quantityKg: Math.round(dayQty * 100) / 100, - amountYuan: Math.round(dayAmount * 100) / 100, - stations: newStations, - }); - } - } - - // 重新计算动态环比 - for (let i = 0; i < result.length; i++) { - const current = result[i]; - const prev = result[i + 1]; // items 是按日期倒序 - if (prev && prev.quantityKg > 0) { - const pct = ((current.quantityKg - prev.quantityKg) / prev.quantityKg) * 100; - current.momPct = Math.round(pct * 10) / 10; - } else { - current.momPct = null; - } - } - - return result; -} - -/** 动态计算 Filter 后的 KPI 汇总数据 */ -export function calculateDailyKpis(filteredItems: DailyItem[], fleetFilter: FleetCategoryFilter) { - const totalQuantityKg = Math.round( - filteredItems.reduce((acc, item) => acc + item.quantityKg, 0) * 10, - ) / 10; - - // 统计内部车辆与外部车辆各自的加氢量 - let ownKg = 0; - let extKg = 0; - filteredItems.forEach((day) => { - day.stations.forEach((st) => { - st.customers.forEach((cust) => { - cust.vehicles.forEach((vh) => { - if (vh.fleetCategory === 'own') { - ownKg += vh.quantityKg; - } else { - extKg += vh.quantityKg; - } - }); - }); - }); - }); - ownKg = Math.round(ownKg * 10) / 10; - extKg = Math.round(extKg * 10) / 10; - - let fleetTypeLabel = '全部车辆'; - let fleetSubLabel = `内部 ${ownKg.toLocaleString('zh-CN')}Kg · 外部 ${extKg.toLocaleString('zh-CN')}Kg`; - - if (fleetFilter === 'own') { - fleetTypeLabel = '羚牛车辆'; - fleetSubLabel = '内部车辆归属口径'; - } else if (fleetFilter === 'external') { - fleetTypeLabel = '外部车辆'; - fleetSubLabel = '外部车辆归属口径'; - } - - const activeDaysCount = filteredItems.length; - const activeDaysStr = `${activeDaysCount} 天`; - const dailyAvgKgNum = activeDaysCount > 0 ? Math.round((totalQuantityKg / activeDaysCount) * 10) / 10 : 0; - const dailyAvgKgStr = `${dailyAvgKgNum.toLocaleString('zh-CN')} Kg`; - - // 站点去重统计 - const stationSet = new Set(); - filteredItems.forEach((d) => d.stations.forEach((s) => stationSet.add(s.stationId))); - const stationCount = stationSet.size; - - // 峰值日与低谷日 - let peakItem: DailyItem | null = null; - let troughItem: DailyItem | null = null; - - filteredItems.forEach((item) => { - if (!peakItem || item.quantityKg > peakItem.quantityKg) { - peakItem = item; - } - if (!troughItem || item.quantityKg < troughItem.quantityKg) { - troughItem = item; - } - }); - - const peakDayLabel = peakItem - ? `${(peakItem as DailyItem).shortDate} · ${Math.round((peakItem as DailyItem).quantityKg).toLocaleString('zh-CN')}` - : '-'; - const troughDayLabel = troughItem - ? `${(troughItem as DailyItem).shortDate} · ${Math.round((troughItem as DailyItem).quantityKg).toLocaleString('zh-CN')}` - : '-'; - - const dateRange = filteredItems.length > 0 - ? `${filteredItems[filteredItems.length - 1].date} 至 ${filteredItems[0].date}` - : '无数据'; - - return { - totalQuantityKg, - dateRange, - fleetTypeLabel, - fleetSubLabel, - ownKg, - extKg, - activeDays: activeDaysStr, - dailyAvgKg: dailyAvgKgStr, - dailyAvgKgNum, - stationCount, - peakDayLabel, - troughDayLabel, - zeroDaysCount: 0, - }; -} diff --git a/src/modules/energy/hydrogen-bi-v2/prototype-source/index.tsx b/src/modules/energy/hydrogen-bi-v2/prototype-source/index.tsx deleted file mode 100644 index 45123e9..0000000 --- a/src/modules/energy/hydrogen-bi-v2/prototype-source/index.tsx +++ /dev/null @@ -1,50 +0,0 @@ -/** - * @name 能源氢费经营看板 - * @description 嵌入 bi-next #hydrogen/overview · 我司成本三维度(非 OneOS V2) · 口令 lingniu - */ -import React, { useEffect, useMemo, useState } from 'react'; -import { createRoot } from 'react-dom/client'; -import { - type AnnotationSourceDocument, - type AnnotationViewerOptions, -} from '@axhub/annotation'; -import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host'; -import { clearHostPrototypeRouteInfo } from '../../common/useHashPage'; -import { EnergyBiAccessGate, isEnergyBiAuthed } from './EnergyBiAccessGate'; -import { EnergyBiBoardApp } from './EnergyBiBoardApp'; -import annotationSourceDocument from './annotation-source.json'; - -function AuthedEnergyBiBoard() { - const [ok, setOk] = useState(() => isEnergyBiAuthed()); - if (!ok) return setOk(true)} />; - return ; -} - -export default function EnergyH2BiBoardEntry() { - useEffect(() => { - clearHostPrototypeRouteInfo(); - }, []); - - const annotationOptions = useMemo( - () => ({ title: '能源氢费经营看板' }), - [], - ); - - return ( - - - - ); -} - -if (typeof document !== 'undefined') { - const container = document.getElementById('root'); - if (container && !container.dataset.energyH2BiBoardMounted) { - container.dataset.energyH2BiBoardMounted = '1'; - const root = createRoot(container); - root.render(); - } -} diff --git a/src/modules/energy/hydrogen-bi-v2/prototype-source/manifest.json b/src/modules/energy/hydrogen-bi-v2/prototype-source/manifest.json deleted file mode 100644 index e17a5e8..0000000 --- a/src/modules/energy/hydrogen-bi-v2/prototype-source/manifest.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "version": 1, - "format": "axhub-published-source", - "sourceRoot": "source", - "entry": "index.tsx", - "files": [ - { - "path": "annotation-source.json", - "kind": "source" - }, - { - "path": "data/aggregates.ts", - "kind": "source" - }, - { - "path": "data/mockBoard.ts", - "kind": "source" - }, - { - "path": "data/mockDaily.ts", - "kind": "source" - }, - { - "path": "EnergyBiAccessGate.tsx", - "kind": "source" - }, - { - "path": "EnergyBiBoardApp.tsx", - "kind": "source" - }, - { - "path": "index.tsx", - "kind": "entry" - }, - { - "path": "styles/energy-bi-board.css", - "kind": "source" - }, - { - "path": "types.ts", - "kind": "source" - } - ] -} diff --git a/src/modules/energy/hydrogen-bi-v2/prototype-source/styles/energy-bi-board.css b/src/modules/energy/hydrogen-bi-v2/prototype-source/styles/energy-bi-board.css deleted file mode 100644 index af2bde4..0000000 --- a/src/modules/energy/hydrogen-bi-v2/prototype-source/styles/energy-bi-board.css +++ /dev/null @@ -1,5132 +0,0 @@ -/** - * 能源 BI 宿主设计令牌(来源:AI-羚牛氢能-能源BI-complete.zip · #hydrogen/overview) - * 本原型独立嵌入 bi-next,禁止引入 OneOS V2 组件; - * 字体例外:汉字/UI 与数字等宽对齐 V2 Token(DESIGN §2.2)。 - */ - -/* —— 访问口令门(轻门禁 · 对齐宿主蓝系,非 V2 组件) —— */ -.ehb-shell--embedded > .ehb-rail, -.ehb-shell--embedded .ehb-opening-watermark { - display: none; -} - -.ehb-gate { - --bi-text: #0f172a; - --bi-muted: #64748b; - --bi-blue: #2563eb; - --bi-line: rgba(15, 23, 42, 0.08); - --bi-danger: #dc2626; - min-height: 100vh; - display: flex; - align-items: center; - justify-content: center; - padding: 24px; - background: - radial-gradient( - 1000px 380px at 15% -5%, - rgba(37, 99, 235, 0.08), - transparent 50% - ), - linear-gradient(160deg, #eff6ff 0%, #f8fafc 45%, #ffffff 100%); - color: var(--bi-text); - font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", - "Microsoft YaHei", "Noto Sans SC", sans-serif; - box-sizing: border-box; -} - -.ehb-gate *, -.ehb-gate *::before, -.ehb-gate *::after { - box-sizing: border-box; -} - -.ehb-gate-card { - width: min(420px, 100%); - padding: 36px 32px 28px; - border: 1px solid var(--bi-line); - border-radius: 16px; - background: #ffffff; - box-shadow: 0 12px 40px rgba(15, 23, 42, 0.08); -} - -.ehb-gate-kicker { - font-size: 11px; - letter-spacing: 0.12em; - color: var(--bi-blue); - margin: 0 0 16px; - font-weight: 600; -} - -.ehb-gate-title { - margin: 0 0 8px; - font-size: 24px; - font-weight: 800; - color: var(--bi-text); -} - -.ehb-gate-sub { - margin: 0 0 24px; - font-size: 13px; - line-height: 1.55; - color: var(--bi-muted); -} - -.ehb-gate-label { - display: block; - font-size: 12px; - font-weight: 600; - color: var(--bi-muted); - margin-bottom: 8px; -} - -.ehb-gate-input { - display: block; - width: 100%; - height: 44px; - min-height: 44px; - border-radius: 10px; - border: 1px solid var(--bi-line); - background: #f8fafc; - color: var(--bi-text); - padding: 0 14px; - font-size: 16px; - line-height: 44px; - appearance: none; - -webkit-appearance: none; -} - -.ehb-gate-input::placeholder { - color: #94a3b8; -} - -.ehb-gate-input:hover { - border-color: #cbd5e1; -} - -.ehb-gate-input:focus { - outline: none; - border-color: var(--bi-blue); - background: #fff; - box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18); -} - -.ehb-gate-error { - min-height: 20px; - margin: 8px 0 12px; - font-size: 12px; - color: var(--bi-danger); -} - -.ehb-gate-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 100%; - height: 44px; - min-height: 44px; - border: none; - border-radius: 10px; - background: var(--bi-blue); - color: #fff; - font-size: 15px; - font-weight: 700; - cursor: pointer; -} - -.ehb-gate-btn:hover { - background: #1d4ed8; -} - -.ehb-gate-btn:focus-visible { - outline: 2px solid #1e40af; - outline-offset: 2px; -} - -.ehb-gate-foot { - margin: 16px 0 0; - font-size: 11px; - color: #64748b; - text-align: center; -} - -.ehb-shell { - --bi-app-bg: #f8fafc; - --bi-panel: #ffffff; - --bi-hairline: rgba(15, 23, 42, 0.08); - --bi-hairline-subtle: rgba(15, 23, 42, 0.04); - --bi-text: #0f172a; - --bi-text-body: #1e293b; - --bi-text-sub: #334155; - --bi-muted: #64748b; - --bi-tertiary: #94a3b8; - --bi-blue: #2563eb; - --bi-blue-soft: #eff6ff; - --bi-green: #059669; - --bi-amber: #d97706; - --bi-red: #dc2626; - --bi-purple: #7c3aed; - --bi-cyan: #0891b2; - --bi-rail: #0f172a; - --bi-shadow: - 0 1px 2px rgba(15, 23, 42, 0.04), 0 4px 16px rgba(15, 23, 42, 0.03); - --bi-shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04); - --bi-radius: 14px; - --bi-radius-sm: 10px; - --bi-font: - -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", - "Microsoft YaHei", "Noto Sans SC", sans-serif; - --bi-font-mono: - "JetBrains Mono", "Cascadia Mono", "Cascadia Code", Consolas, "SF Mono", - SFMono-Regular, Menlo, "Courier New", monospace; - - display: flex; - min-height: 100vh; - background: - radial-gradient( - 1000px 380px at 15% -5%, - rgba(37, 99, 235, 0.04), - transparent 50% - ), - var(--bi-app-bg); - color: var(--bi-text-body); - font-family: var(--bi-font); - font-variant-numeric: tabular-nums; -} - -.ehb-rail { - width: 72px; - flex-shrink: 0; - background: var(--bi-rail); - color: #e2e8f0; - display: flex; - flex-direction: column; - align-items: center; - padding: 16px 0; - gap: 8px; -} - -.ehb-rail__item { - width: 56px; - border: none; - background: transparent; - color: #94a3b8; - border-radius: 10px; - padding: 10px 4px; - cursor: pointer; - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - font-size: 11px; - font-weight: 500; - transition: - background 0.15s ease, - color 0.15s ease; -} - -.ehb-rail__item.is-active { - background: var(--bi-blue); - color: #fff; - font-weight: 600; -} - -.ehb-rail__item:disabled { - opacity: 0.35; - cursor: not-allowed; -} - -.ehb-body { - flex: 1; - min-width: 0; - padding: 18px 22px 36px; - box-sizing: border-box; -} - -/* —— 页头 —— */ -.ehb-chrome { - position: sticky; - top: 0; - z-index: 20; - display: flex; - flex-wrap: wrap; - align-items: flex-end; - justify-content: space-between; - gap: 12px 16px; - margin: -6px -6px 16px; - padding: 10px 6px 12px; - background: rgba(248, 250, 252, 0.92); - backdrop-filter: blur(8px); - border-bottom: 1px solid var(--bi-hairline-subtle); -} - -.ehb-chrome__lead h1 { - margin: 2px 0 0; - font-size: 22px; - font-weight: 700; - color: var(--bi-text); - letter-spacing: -0.02em; - line-height: 1.2; -} - -.ehb-crumb { - font-size: 12px; - color: var(--bi-muted); - font-weight: 400; -} - -.ehb-chrome__tools { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 10px; -} - -.ehb-chrome__clock { - font-size: 12px; - color: var(--bi-tertiary); - font-family: var(--bi-font-mono); - font-variant-numeric: tabular-nums; -} - -.ehb-seg { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 3px; - background: #f1f5f9; - border-radius: 10px; - padding: 3px; - min-width: 150px; -} - -.ehb-seg button { - border: none; - background: transparent; - border-radius: 7px; - height: 30px; - padding: 0 12px; - font-size: 12px; - font-weight: 500; - color: var(--bi-muted); - cursor: pointer; - transition: - background 0.15s ease, - color 0.15s ease; -} - -.ehb-seg button.is-active { - background: #ffffff; - color: var(--bi-blue); - font-weight: 600; - box-shadow: var(--bi-shadow-sm); -} - -.ehb-year-select-wrapper { - position: relative; - display: inline-block; -} - -.ehb-year-select-btn { - display: inline-flex; - align-items: center; - gap: 6px; - height: 28px; - padding: 0 12px; - background: #ffffff; - border: 1px solid var(--bi-hairline); - border-radius: 999px; - font-size: 12px; - font-weight: 600; - color: #1e293b; - font-family: var(--bi-font-mono); - cursor: pointer; - box-shadow: var(--bi-shadow-sm); - transition: all 0.15s ease; -} - -.ehb-year-select-btn:hover, -.ehb-year-select-btn.is-active { - border-color: #0284c7; - color: #0284c7; - box-shadow: 0 0 0 2px rgba(2, 132, 199, 0.12); -} - -.ehb-year-dropdown { - position: absolute; - top: calc(100% + 6px); - right: 0; - z-index: 1000; - width: 140px; - background: #ffffff; - border: 1px solid #cbd5e1; - border-radius: 10px; - padding: 6px; - box-shadow: - 0 10px 25px -5px rgba(0, 0, 0, 0.12), - 0 8px 10px -6px rgba(0, 0, 0, 0.08); - font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; -} - -.ehb-year-dropdown__header { - padding: 4px 8px 6px; - font-size: 11px; - font-weight: 600; - color: #94a3b8; - border-bottom: 1px solid #f1f5f9; - margin-bottom: 4px; -} - -.ehb-year-dropdown__list { - display: flex; - flex-direction: column; - gap: 2px; - max-height: 200px; - overflow-y: auto; -} - -.ehb-year-dropdown__item { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - padding: 6px 10px; - border: none; - background: transparent; - border-radius: 6px; - font-size: 12px; - font-family: var(--bi-font-mono); - font-weight: 500; - color: #334155; - cursor: pointer; - transition: all 0.12s ease; -} - -.ehb-year-dropdown__item:hover { - background: #f1f5f9; - color: #0284c7; -} - -.ehb-year-dropdown__item.is-selected { - background: #e0f2fe; - color: #0284c7; - font-weight: 700; -} - -.ehb-year-check { - font-size: 12px; - font-weight: 700; - color: #0284c7; -} - -.ehb-btn { - display: inline-flex; - align-items: center; - gap: 6px; - height: 30px; - padding: 0 11px; - border-radius: 8px; - border: 1px solid var(--bi-hairline); - background: #ffffff; - color: var(--bi-text-body); - font-size: 12px; - font-weight: 500; - cursor: pointer; - transition: - border-color 0.15s ease, - color 0.15s ease; -} - -.ehb-btn:hover { - border-color: rgba(37, 99, 235, 0.35); - color: var(--bi-blue); -} - -.ehb-btn:focus-visible, -.ehb-chip:focus-visible, -.ehb-seg button:focus-visible, -.ehb-year button:focus-visible, -.ehb-dim:focus-visible, -.ehb-dim__sub:focus-visible, -.ehb-stats__tabs button:focus-visible { - outline: 2px solid rgba(37, 99, 235, 0.4); - outline-offset: 1px; -} - -.ehb-btn--ghost { - background: transparent; - color: var(--bi-muted); -} - -.ehb-chip-group { - display: inline-flex; - flex-wrap: wrap; - gap: 4px; - padding: 3px; - border-radius: 999px; - background: #f1f5f9; -} - -.ehb-chip { - height: 26px; - padding: 0 11px; - border-radius: 999px; - border: 1px solid transparent; - background: transparent; - font-size: 12px; - font-weight: 500; - color: var(--bi-muted); - cursor: pointer; - transition: - background 0.15s ease, - color 0.15s ease; -} - -.ehb-chip.is-active { - background: #ffffff; - color: var(--bi-blue); - font-weight: 600; - box-shadow: var(--bi-shadow-sm); -} - -.ehb-filters { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; -} - -.ehb-filters__rule { - width: 1px; - height: 18px; - background: var(--bi-hairline); -} - -/* —— 宿主总览区(V3 双层卡) —— */ -.ehb-host { - margin-bottom: 20px; - padding: 14px; - background: rgba(255, 255, 255, 0.6); - border: 1px dashed rgba(148, 163, 184, 0.3); - border-radius: var(--bi-radius); -} - -.ehb-host-kpi { - display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: 10px; - margin-bottom: 10px; -} - -.ehb-kpi-dual { - background: #ffffff; - border-radius: var(--bi-radius-sm); - border: 1px solid var(--bi-hairline); - padding: 10px; - display: flex; - flex-direction: column; - box-sizing: border-box; -} - -.ehb-mbar-col, -.ehb-rev-bar, -.ehb-top-station-item, -.ehb-region-legend-item, -.ehb-daily-bar-col { - border: 0; - background: transparent; - padding: 0; - color: inherit; - font: inherit; - text-align: inherit; -} - -.ehb-top-station-item, -.ehb-region-legend-item { - width: 100%; -} - -.ehb-mbar-col, -.ehb-rev-bar, -.ehb-top-station-item, -.ehb-region-legend-item, -.ehb-daily-bar-col { - cursor: pointer; -} - -.ehb-kpi-dual__head { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 4px; -} - -.ehb-kpi-dual__label { - font-size: 12px; - font-weight: 500; - color: var(--bi-muted); -} - -.ehb-kpi-dual__badge { - width: 22px; - height: 22px; - border-radius: 6px; - display: grid; - place-items: center; - flex-shrink: 0; -} - -.ehb-kpi-dual__badge.is-blue { - background: var(--bi-blue-soft); - color: var(--bi-blue); -} -.ehb-kpi-dual__badge.is-green { - background: #ecfdf5; - color: var(--bi-green); -} -.ehb-kpi-dual__badge.is-amber { - background: #fffbeb; - color: var(--bi-amber); -} -.ehb-kpi-dual__badge.is-purple { - background: #f3e8ff; - color: var(--bi-purple); -} -.ehb-kpi-dual__badge.is-cyan { - background: #ecfeff; - color: var(--bi-cyan); -} - -.ehb-kpi-dual__val { - display: flex; - align-items: baseline; - color: var(--bi-text-body); - font-family: var(--bi-font-mono); - font-variant-numeric: tabular-nums; - line-height: 1.2; - margin-bottom: 6px; -} - -.ehb-kpi-dual__symbol { - font-size: 13px; - font-weight: 600; - color: var(--bi-muted); - margin-right: 2px; -} - -.ehb-kpi-dual__num { - font-size: 20px; - font-weight: 700; - letter-spacing: -0.02em; -} - -.ehb-kpi-dual__unit { - font-size: 12px; - font-weight: 500; - color: var(--bi-muted); - margin-left: 2px; -} - -.ehb-kpi-dual__deck { - background: #f8fafc; - border-radius: 6px; - padding: 4px 8px; - display: flex; - justify-content: space-between; - align-items: center; - font-size: 11px; - color: var(--bi-muted); - font-family: var(--bi-font-mono); - font-variant-numeric: tabular-nums; -} - -.ehb-insight { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 10px; -} - -.ehb-insight__card { - background: #ffffff; - border-radius: var(--bi-radius-sm); - border: 1px solid var(--bi-hairline); - padding: 10px 12px; - display: flex; - gap: 10px; - align-items: flex-start; -} - -.ehb-insight__icon { - width: 32px; - height: 32px; - border-radius: 8px; - background: var(--bi-blue-soft); - color: var(--bi-blue); - display: grid; - place-items: center; - flex-shrink: 0; -} - -.ehb-insight__icon.is-down { - background: #fef2f2; - color: var(--bi-red); -} - -.ehb-insight__icon.is-ok { - background: #ecfdf5; - color: var(--bi-green); -} - -.ehb-insight__title { - font-size: 11px; - color: var(--bi-muted); - font-weight: 500; -} - -.ehb-insight__value { - font-size: 18px; - font-weight: 700; - margin-top: 1px; - color: var(--bi-text-body); - font-family: var(--bi-font-mono); - font-variant-numeric: tabular-nums; -} - -.ehb-insight__value.is-neg { - color: var(--bi-red); -} -.ehb-insight__value.is-pos { - color: var(--bi-green); -} - -.ehb-insight__value.is-info { - color: var(--bi-blue); -} - -.ehb-insight__desc { - font-size: 11px; - color: var(--bi-tertiary); - margin-top: 2px; - line-height: 1.35; -} - -.ehb-insight__card--rank { - position: relative; - align-items: center; -} - -.ehb-insight__card--rank.is-open { - border-color: #7dd3fc; - box-shadow: 0 0 0 2px rgba(2, 132, 199, 0.12); - z-index: 30; -} - -.ehb-insight__rank-body { - flex: 1; - min-width: 0; -} - -.ehb-insight__rank-chevron { - flex-shrink: 0; - color: var(--bi-muted); - transition: transform 0.2s ease; - margin-left: auto; -} - -.ehb-insight__rank-chevron.is-open { - transform: rotate(180deg); - color: #0284c7; -} - -.ehb-station-rank-dropdown { - position: absolute; - top: calc(100% + 6px); - left: 0; - right: 0; - min-width: 360px; - max-width: min(520px, 92vw); - background: #ffffff; - border: 1px solid #e2e8f0; - border-radius: 10px; - box-shadow: 0 12px 32px rgba(15, 23, 42, 0.14); - z-index: 40; - overflow: hidden; -} - -.ehb-station-rank-dropdown__head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 10px 12px; - border-bottom: 1px solid #e2e8f0; - font-size: 12px; - font-weight: 700; - color: #0f172a; -} - -.ehb-station-rank-dropdown__meta { - font-size: 11px; - font-weight: 500; - color: #64748b; -} - -.ehb-station-rank-dropdown__list { - max-height: 320px; - overflow-y: auto; - padding: 6px; - -webkit-overflow-scrolling: touch; -} - -.ehb-station-rank-item { - display: grid; - grid-template-columns: 28px minmax(0, 1fr) auto auto; - align-items: center; - gap: 8px; - width: 100%; - border: none; - background: transparent; - padding: 8px 8px; - border-radius: 8px; - cursor: pointer; - text-align: left; -} - -.ehb-station-rank-item:hover { - background: #f0f9ff; -} - -.ehb-station-rank-item__rank { - width: 22px; - height: 22px; - border-radius: 6px; - display: grid; - place-items: center; - font-size: 11px; - font-weight: 700; - font-family: var(--bi-font-mono); - color: #64748b; - background: #f1f5f9; -} - -.ehb-station-rank-item__rank.is-top { - color: #fff; - background: #0284c7; -} - -.ehb-station-rank-item__main { - min-width: 0; - display: flex; - flex-direction: column; - gap: 4px; -} - -.ehb-station-rank-item__name { - font-size: 12px; - color: #0f172a; - font-weight: 600; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.ehb-station-rank-item__bar { - height: 4px; - border-radius: 999px; - background: #e2e8f0; - overflow: hidden; -} - -.ehb-station-rank-item__bar > span { - display: block; - height: 100%; - border-radius: inherit; - background: linear-gradient(90deg, #38bdf8, #0284c7); -} - -.ehb-station-rank-item__val { - font-size: 12px; - font-weight: 700; - font-family: var(--bi-font-mono); - font-variant-numeric: tabular-nums; - color: #0f172a; - white-space: nowrap; -} - -.ehb-station-rank-item__share { - font-size: 11px; - color: #64748b; - font-family: var(--bi-font-mono); - font-variant-numeric: tabular-nums; - min-width: 42px; - text-align: right; -} - -.ehb-station-rank-empty { - padding: 20px 12px; - text-align: center; - font-size: 12px; - color: #94a3b8; -} - -/* —— 核心区:我司成本 —— */ -.ehb-feature { - background: #ffffff; - border-radius: var(--bi-radius); - box-shadow: - 0 2px 8px rgba(15, 23, 42, 0.04), - 0 1px 2px rgba(15, 23, 42, 0.02); - border: 1px solid rgba(37, 99, 235, 0.18); - padding: 18px 20px 20px; -} - -.ehb-feature__bar { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 16px; - padding-bottom: 14px; - border-bottom: 1px solid var(--bi-hairline); -} - -.ehb-feature__bar h2 { - margin: 0; - font-size: 18px; - font-weight: 700; - color: var(--bi-text); - letter-spacing: -0.02em; - display: flex; - align-items: center; - gap: 8px; -} - -.ehb-feature__bar h2::before { - content: ""; - display: inline-block; - width: 4px; - height: 16px; - border-radius: 999px; - background: var(--bi-blue); -} - -.ehb-dim-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 12px; - margin-bottom: 14px; -} - -.ehb-dim { - position: relative; - text-align: left; - border: 1px solid var(--bi-hairline); - background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); - border-radius: var(--bi-radius-sm); - padding: 14px 16px 12px; - cursor: pointer; - overflow: hidden; - transition: - border-color 0.15s ease, - box-shadow 0.15s ease, - transform 0.15s ease; -} - -.ehb-dim::before { - content: ""; - position: absolute; - inset: 0 0 auto; - height: 3px; - background: var(--bi-blue); -} - -.ehb-dim.is-lease::before { - background: linear-gradient(90deg, #2563eb, #60a5fa); -} -.ehb-dim.is-logistics::before { - background: linear-gradient(90deg, #0891b2, #22d3ee); -} -.ehb-dim.is-ops::before { - background: linear-gradient(90deg, #7c3aed, #a78bfa); -} - -.ehb-dim:hover { - border-color: rgba(37, 99, 235, 0.3); - transform: translateY(-1px); -} - -.ehb-dim.is-active { - background: var(--bi-blue-soft); - border-color: rgba(37, 99, 235, 0.45); - box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); -} - -@media (prefers-reduced-motion: reduce) { - .ehb-dim, - .ehb-rail__item, - .ehb-chip, - .ehb-seg button, - .ehb-btn { - transition: none; - } - .ehb-dim:hover { - transform: none; - } -} - -.ehb-dim__name { - font-size: 13px; - font-weight: 600; - color: var(--bi-muted); -} - -.ehb-dim__amt { - margin-top: 4px; - font-size: 22px; - font-weight: 800; - color: var(--bi-text); - font-family: var(--bi-font-mono); - font-variant-numeric: tabular-nums; - letter-spacing: -0.02em; -} - -.ehb-dim__subs { - margin-top: 10px; - display: grid; - gap: 5px; -} - -.ehb-dim__sub { - display: flex; - justify-content: space-between; - align-items: center; - font-size: 12px; - color: var(--bi-muted); - padding: 5px 8px; - border-radius: 6px; - background: rgba(255, 255, 255, 0.85); - border: 1px solid transparent; - cursor: pointer; - transition: - background 0.12s ease, - border-color 0.12s ease; -} - -.ehb-dim__sub:hover { - background: #ffffff; - border-color: rgba(148, 163, 184, 0.25); -} - -.ehb-dim__sub.is-active { - border-color: rgba(37, 99, 235, 0.35); - color: var(--bi-blue); - font-weight: 600; - background: #ffffff; -} - -.ehb-dim__sub strong { - color: var(--bi-text-sub); - font-weight: 600; - font-family: var(--bi-font-mono); - font-variant-numeric: tabular-nums; -} - -.ehb-pending { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px 12px; - padding: 8px 12px; - border-radius: 8px; - background: #fffbeb; - border: 1px solid rgba(217, 119, 6, 0.22); - font-size: 12px; - color: #92400e; - margin-bottom: 14px; -} - -.ehb-pending strong { - font-family: var(--bi-font-mono); - font-variant-numeric: tabular-nums; - font-weight: 600; -} - -.ehb-pending__sep { - width: 1px; - height: 12px; - background: rgba(217, 119, 6, 0.28); -} - -/* —— 面板与表格 —— */ -.ehb-panel { - margin-top: 14px; - border: 1px solid var(--bi-hairline); - border-radius: var(--bi-radius-sm); - background: #ffffff; - overflow: hidden; -} - -.ehb-panel__head { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 8px 12px; - padding: 8px 12px; - border-bottom: 1px solid var(--bi-hairline); - background: #f8fafc; -} - -.ehb-panel__meta { - font-size: 12px; - color: var(--bi-tertiary); -} - -.ehb-stats__tabs { - display: inline-flex; - gap: 3px; - background: #e2e8f0; - border-radius: 8px; - padding: 3px; -} - -.ehb-stats__tabs button { - border: none; - background: transparent; - height: 28px; - padding: 0 11px; - border-radius: 6px; - font-size: 12px; - font-weight: 500; - color: var(--bi-muted); - cursor: pointer; -} - -.ehb-stats__tabs button.is-active { - background: #ffffff; - color: var(--bi-blue); - font-weight: 600; - box-shadow: var(--bi-shadow-sm); -} - -.ehb-stats__path { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px; - font-size: 12px; - color: var(--bi-muted); -} - -.ehb-stats__path button { - border: none; - background: transparent; - color: var(--bi-blue); - font-weight: 600; - cursor: pointer; - padding: 0; -} - -.ehb-section-title { - margin: 0; - font-size: 13px; - font-weight: 600; - color: var(--bi-text-body); -} - -.ehb-table-wrap { - overflow: auto; - max-height: min(40vh, 400px); - background: #ffffff; -} - -.ehb-panel .ehb-table-wrap { - border: none; - border-radius: 0; -} - -.ehb-table { - width: 100%; - border-collapse: separate; - border-spacing: 0; - min-width: max(100%, 720px); - font-size: 13px; -} - -.ehb-table th { - position: sticky; - top: 0; - z-index: 1; - text-align: left; - font-size: 12px; - font-weight: 500; - color: var(--bi-muted); - background-color: #f8fafc; - background-clip: padding-box; - transform: translateZ(0); - padding: 8px 12px; - border-bottom: 1px solid var(--bi-hairline); - white-space: nowrap; -} - -.ehb-table td { - padding: 8px 12px; - border-bottom: 1px solid var(--bi-hairline); - color: var(--bi-text-body); - font-weight: 400; -} - -.ehb-table tr:last-child td { - border-bottom: none; -} - -.ehb-table tr.is-clickable { - cursor: pointer; -} - -.ehb-table tr.is-clickable:hover td { - background: #f1f5f9; -} - -.ehb-table tr.is-active td { - background: rgba(37, 99, 235, 0.08); -} - -.ehb-mono { - font-variant-numeric: tabular-nums; - font-family: var(--bi-font-mono); - font-size: 12px; - color: var(--bi-text-sub); -} - -.ehb-mono.ehb-idx { - color: var(--bi-tertiary); - font-weight: 400; -} - -.ehb-badge { - display: inline-flex; - align-items: center; - height: 20px; - padding: 0 7px; - border-radius: 999px; - font-size: 11px; - font-weight: 600; -} - -.ehb-badge.is-ok { - background: #ecfdf5; - color: #047857; -} - -.ehb-badge.is-warn { - background: #fffbeb; - color: #b45309; -} - -.ehb-empty { - padding: 36px 16px; - text-align: center; - color: var(--bi-muted); - font-size: 13px; -} - -.ehb-empty__icon { - color: var(--bi-blue); -} - -.ehb-empty__title { - margin-top: 8px; - font-weight: 600; - color: var(--bi-text-body); -} - -.ehb-live-data-state { - display: flex; - min-height: 240px; - align-items: center; - justify-content: center; - flex-direction: column; - gap: 10px; - padding: 32px 20px; - border: 1px solid #dbe4f1; - border-radius: 14px; - background: #fff; - color: #64748b; - text-align: center; -} - -.ehb-live-data-state strong { - color: #172238; - font-size: 16px; -} - -.ehb-live-data-state.is-error { - border-color: #fecaca; - background: #fffafa; -} - -.ehb-live-data-state.is-error strong { color: #b42318; } - -.ehb-live-data-spinner { - width: 24px; - height: 24px; - border: 3px solid #dbe7ff; - border-top-color: #2f6bff; - border-radius: 50%; - animation: ehb-live-data-spin .75s linear infinite; -} - -@keyframes ehb-live-data-spin { to { transform: rotate(360deg); } } - -@media (prefers-reduced-motion: reduce) { - .ehb-live-data-spinner { animation: none; } -} - -/* —— 宿主按日视图 (Daily View) 专用样式 —— */ - -.ehb-daily-filter-card { - background: #ffffff; - border-radius: var(--bi-radius); - border: 1px solid var(--bi-hairline); - padding: 12px 16px; - margin-bottom: 12px; - display: flex; - flex-direction: column; - gap: 12px; -} - -.ehb-daily-filter-row { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.ehb-daily-filter-group { - display: flex; - align-items: center; - gap: 8px; -} - -.ehb-pill-tabs { - display: flex; - background: #f1f5f9; - border-radius: 6px; - padding: 2px; - gap: 2px; -} - -.ehb-pill-btn { - border: none; - background: transparent; - padding: 4px 12px; - font-size: 12px; - font-weight: 500; - color: var(--bi-muted); - border-radius: 4px; - cursor: pointer; - transition: all 0.15s ease; -} - -.ehb-pill-btn:hover { - color: var(--bi-text-body); -} - -.ehb-pill-btn.is-active { - background: #ffffff; - color: var(--bi-blue); - font-weight: 600; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); -} - -.ehb-daily-date-picker-wrapper { - position: relative; - display: inline-block; -} - -.ehb-daily-date-picker { - display: flex; - align-items: center; - gap: 8px; - background: #ffffff; - border: 1px solid #cbd5e1; - border-radius: 6px; - padding: 4px 10px; - font-size: 12px; - color: var(--bi-text-body); - font-family: var(--bi-font-mono); - cursor: pointer; - user-select: none; - transition: all 0.15s ease; -} - -.ehb-daily-date-picker:hover, -.ehb-daily-date-picker.is-active { - border-color: #0284c7; - box-shadow: 0 0 0 2px rgba(2, 132, 199, 0.12); -} - -.ehb-date-label { - color: #64748b; - font-weight: 500; -} - -.ehb-date-val { - color: #0f172a; - font-weight: 600; -} - -/* 自定义非原生日历 Popover 下拉卡片 */ -.ehb-date-popover { - position: absolute; - top: calc(100% + 6px); - left: 0; - z-index: 1000; - width: 238px; - background: #ffffff; - border: 1px solid #cbd5e1; - border-radius: 8px; - padding: 10px; - box-shadow: - 0 10px 25px -5px rgba(0, 0, 0, 0.12), - 0 8px 10px -6px rgba(0, 0, 0, 0.08); - font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; -} - -/* 模式切换条: 按日 | 按月 | 按年 */ -.ehb-dp-mode-bar { - display: flex; - background: #f1f5f9; - border-radius: 6px; - padding: 2px; - gap: 2px; - margin-bottom: 8px; -} - -.ehb-dp-mode-btn { - flex: 1; - border: none; - background: transparent; - padding: 3px 0; - font-size: 11px; - font-weight: 500; - color: #64748b; - border-radius: 4px; - cursor: pointer; - transition: all 0.12s ease; - text-align: center; -} - -.ehb-dp-mode-btn.is-active { - background: #ffffff; - color: #0284c7; - font-weight: 600; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); -} - -.ehb-dp-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 8px; -} - -.ehb-dp-title-group { - display: flex; - align-items: center; - gap: 4px; -} - -.ehb-dp-title-btn { - border: none; - background: transparent; - padding: 2px 6px; - border-radius: 4px; - font-size: 13px; - font-weight: 700; - color: #0f172a; - cursor: pointer; - transition: all 0.12s ease; -} - -.ehb-dp-title-btn:hover { - background: #f1f5f9; - color: #0284c7; -} - -.ehb-dp-title-btn.is-active { - color: #0284c7; - background: #e0f2fe; -} - -.ehb-dp-title { - font-size: 13px; - font-weight: 700; - color: #0f172a; -} - -.ehb-dp-nav-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - border: none; - background: #f1f5f9; - border-radius: 4px; - color: #475569; - cursor: pointer; - transition: all 0.15s ease; -} - -.ehb-dp-nav-btn:hover { - background: #e2e8f0; - color: #0284c7; -} - -.ehb-dp-week-row { - display: grid; - grid-template-columns: repeat(7, 1fr); - text-align: center; - font-size: 11px; - font-weight: 600; - color: #94a3b8; - margin-bottom: 6px; -} - -.ehb-dp-grid { - display: grid; - grid-template-columns: repeat(7, 1fr); - gap: 2px; -} - -.ehb-dp-day { - display: flex; - align-items: center; - justify-content: center; - height: 26px; - border: none; - background: transparent; - border-radius: 4px; - font-size: 12px; - font-family: var(--bi-font-mono); - color: #334155; - cursor: pointer; - transition: all 0.12s ease; -} - -.ehb-dp-day:hover:not(.is-selected):not(.is-empty) { - background: #f1f5f9; - color: #0284c7; -} - -.ehb-dp-day.is-selected { - background: #0284c7; - color: #ffffff; - font-weight: 700; -} - -.ehb-dp-day.is-empty { - cursor: default; -} - -/* 月选择网格 */ -.ehb-dp-month-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 6px; - padding: 4px 0; -} - -.ehb-dp-month-item { - height: 34px; - border: 1px solid #e2e8f0; - background: #ffffff; - border-radius: 6px; - font-size: 12px; - font-weight: 500; - color: #334155; - cursor: pointer; - transition: all 0.12s ease; -} - -.ehb-dp-month-item:hover:not(.is-selected) { - border-color: #38bdf8; - color: #0284c7; - background: #f0f9ff; -} - -.ehb-dp-month-item.is-selected { - background: #0284c7; - border-color: #0284c7; - color: #ffffff; - font-weight: 700; -} - -/* 年选择网格 */ -.ehb-dp-year-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 6px; - padding: 4px 0; -} - -.ehb-dp-year-item { - height: 34px; - border: 1px solid #e2e8f0; - background: #ffffff; - border-radius: 6px; - font-size: 12px; - font-family: var(--bi-font-mono); - font-weight: 500; - color: #334155; - cursor: pointer; - transition: all 0.12s ease; -} - -.ehb-dp-year-item:hover:not(.is-selected) { - border-color: #38bdf8; - color: #0284c7; - background: #f0f9ff; -} - -.ehb-dp-year-item.is-selected { - background: #0284c7; - border-color: #0284c7; - color: #ffffff; - font-weight: 700; -} - -.ehb-fleet-segmented { - display: flex; - background: #f8fafc; - border: 1px solid #e2e8f0; - border-radius: 6px; - padding: 2px; - gap: 4px; -} - -.ehb-fleet-btn { - display: flex; - align-items: center; - gap: 6px; - border: none; - background: transparent; - padding: 5px 14px; - font-size: 12px; - font-weight: 500; - color: var(--bi-muted); - border-radius: 4px; - cursor: pointer; - transition: all 0.15s ease; -} - -.ehb-fleet-btn.is-active { - background: #ffffff; - color: var(--bi-blue); - font-weight: 600; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); -} - -.ehb-daily-kpi-grid { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 12px; - margin-bottom: 12px; -} - -.ehb-daily-kpi-card { - background: #ffffff; - border-radius: var(--bi-radius-sm); - border: 1px solid var(--bi-hairline); - padding: 12px 16px; - display: flex; - flex-direction: column; - position: relative; -} - -.ehb-daily-kpi-head { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 6px; -} - -.ehb-daily-kpi-title { - font-size: 12px; - font-weight: 500; - color: var(--bi-muted); -} - -.ehb-daily-kpi-val { - font-size: 24px; - font-weight: 800; - color: var(--bi-text-body); - font-family: var(--bi-font-mono); - font-variant-numeric: tabular-nums; - line-height: 1.2; - margin-bottom: 4px; - display: flex; - align-items: baseline; - gap: 3px; -} - -.ehb-daily-kpi-sub { - font-size: 11px; - color: var(--bi-tertiary); - font-family: var(--bi-font-mono); -} - -.ehb-daily-chart-section { - background: #ffffff; - border-radius: var(--bi-radius); - border: 1px solid var(--bi-hairline); - padding: 16px; - margin-bottom: 12px; -} - -.ehb-daily-chart-head { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 12px; -} - -.ehb-daily-chart-title { - font-size: 14px; - font-weight: 700; - color: var(--bi-text-body); -} - -.ehb-daily-chart-meta-group { - display: flex; - align-items: center; - gap: 16px; -} - -.ehb-daily-chart-legend { - display: flex; - align-items: center; - gap: 12px; -} - -.ehb-legend-item { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 12px; - font-weight: 500; - color: #475569; -} - -.ehb-legend-dot { - width: 8px; - height: 8px; - border-radius: 2px; -} - -.ehb-legend-dot.is-own { - background: #0284c7; -} - -.ehb-legend-dot.is-ext { - background: #f59e0b; -} - -.ehb-daily-chart-meta { - font-size: 11px; - color: var(--bi-tertiary); -} - -.ehb-daily-summary-pills { - display: flex; - gap: 16px; - margin-bottom: 16px; - background: #f8fafc; - padding: 8px 12px; - border-radius: 6px; -} - -.ehb-daily-pill-item { - display: flex; - align-items: center; - gap: 6px; - font-size: 11px; - color: var(--bi-muted); -} - -.ehb-daily-pill-item strong { - color: var(--bi-text-body); - font-weight: 700; - font-family: var(--bi-font-mono); -} - -.ehb-daily-bar-container { - height: 200px; - display: flex; - align-items: flex-end; - gap: 8px; - padding-top: 24px; - padding-bottom: 24px; - position: relative; - border-bottom: 1px solid #e2e8f0; - overflow-x: auto; - overflow-y: hidden; - scrollbar-width: thin; -} - -.ehb-daily-avg-line { - position: absolute; - left: 0; - right: 0; - min-width: 100%; - border-top: 1.5px dashed #2563eb; - opacity: 0.85; - pointer-events: none; - z-index: 5; -} - -.ehb-daily-avg-label { - position: sticky; - left: 8px; - top: -11px; - font-size: 11px; - font-weight: 600; - color: #1e40af; - background: #eff6ff; - border: 1px solid #93c5fd; - padding: 1px 8px; - border-radius: 4px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); - white-space: nowrap; -} - -.ehb-daily-bar-col { - flex: 1; - min-width: 18px; - display: flex; - flex-direction: column; - align-items: center; - height: 100%; - justify-content: flex-end; - position: relative; - cursor: pointer; -} - -.ehb-daily-bar-fill { - width: 100%; - max-width: 28px; - background: linear-gradient(180deg, #38bdf8 0%, #0284c7 100%); - border-radius: 4px 4px 0 0; - transition: all 0.2s ease; - position: relative; -} - -.ehb-daily-bar-fill.is-stacked { - display: flex; - flex-direction: column; - overflow: hidden; - background: transparent; -} - -.ehb-daily-bar-fill.is-stacked.is-active { - box-shadow: 0 0 10px rgba(2, 132, 199, 0.5); -} - -.ehb-bar-segment { - width: 100%; - transition: all 0.2s ease; -} - -.ehb-bar-segment.is-ext { - background: linear-gradient(180deg, #fbbf24 0%, #f59e0b 100%); - border-bottom: 1px solid rgba(255, 255, 255, 0.5); -} - -.ehb-bar-segment.is-own { - background: linear-gradient(180deg, #38bdf8 0%, #0284c7 100%); -} - -.ehb-daily-bar-col:hover .ehb-bar-segment.is-ext { - filter: brightness(1.1); -} - -.ehb-daily-bar-col:hover .ehb-bar-segment.is-own { - filter: brightness(1.1); -} - -.ehb-daily-bar-val { - position: absolute; - top: -20px; - font-size: 10px; - color: var(--bi-muted); - font-family: var(--bi-font-mono); - white-space: nowrap; -} - -.ehb-daily-bar-label { - margin-top: 8px; - font-size: 10px; - color: var(--bi-tertiary); - font-family: var(--bi-font-mono); -} - -.ehb-daily-table-card { - background: #ffffff; - border-radius: var(--bi-radius); - border: 1px solid var(--bi-hairline); - padding: 16px; -} - -.ehb-daily-table-head { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 12px; -} - -.ehb-daily-table-title { - font-size: 14px; - font-weight: 700; - color: var(--bi-text-body); -} - -.ehb-title-sub { - font-size: 12px; - font-weight: 400; - color: var(--bi-tertiary); - margin-left: 4px; -} - -.ehb-show-h5 { - display: none !important; -} - -.ehb-hide-h5 { - display: inline !important; -} - -.ehb-export-btn { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 12px; - padding: 5px 12px; - border-radius: 6px; - cursor: pointer; - transition: all 0.15s ease; -} - -/* —— 钻取 Badge 标签 —— */ -.ehb-tag { - display: inline-flex; - align-items: center; - gap: 3px; - padding: 1px 6px; - border-radius: 4px; - font-size: 11px; - font-weight: 600; - line-height: 1.4; - cursor: help; -} - -.ehb-tag--self-use { - background: #eff6ff; - color: #2563eb; - border: 1px solid #bfdbfe; -} - -.ehb-tag--ext-sale { - background: #f0fdf4; - color: #16a34a; - border: 1px solid #bbf7d0; -} - -/* 单站拆分内部/外部车辆加氢显示标签 */ -.ehb-station-cell { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 6px; /* 加氢站名称与标签组垂直间距,舒适有呼吸感 */ - padding: 4px 0; -} - -.ehb-station-title-row { - display: inline-flex; - align-items: center; - font-weight: 600; - color: #0f172a; - line-height: 1.4; -} - -.ehb-arrow-icon { - margin-right: 6px; - color: #0284c7; - display: inline-block; - width: 14px; -} - -.ehb-split-tag-group { - display: flex; - align-items: center; - gap: 6px 10px; /* 水平 10px,换行时垂直 6px */ - flex-wrap: wrap; -} - -.ehb-split-tag { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 11px; - padding: 3px 10px; - border-radius: 4px; - border: 1px solid transparent; - line-height: 1.4; - white-space: nowrap; -} - -.ehb-split-tag.is-own { - background: #f0f9ff; - border-color: #bae6fd; - color: #0369a1; -} - -.ehb-split-tag.is-ext { - background: #f8fafc; - border-color: #cbd5e1; - color: #475569; -} - -.ehb-split-tag__label { - font-weight: 600; - padding-right: 6px; - border-right: 1px solid rgba(0, 0, 0, 0.1); -} - -.ehb-split-tag__val { - font-family: var(--bi-font-mono); - font-weight: 700; -} - -.ehb-split-tag__price { - font-family: var(--bi-font-mono); - opacity: 0.88; -} - -.ehb-tag--own-fleet { - background: #f0fdf4; - color: #15803d; - border: 1px solid #bbf7d0; -} - -.ehb-tag--ext-fleet { - background: #f1f5f9; - color: #64748b; - border: 1px solid #e2e8f0; -} - -.ehb-tag--ext-cust { - background: #fff7ed; - color: #c2410c; - border: 1px solid #ffedd5; -} - -.ehb-tag--source-api { - background: #e0f2fe; - color: #0369a1; -} - -.ehb-tag--source-station { - background: #fff7ed; - color: #c2410c; -} - -.ehb-tag--source-lingniu { - background: #faf5ff; - color: #7e22ce; -} - -.ehb-tag--verify-ok { - background: #ecfdf5; - color: #047857; - border: 1px solid #a7f3d0; -} - -.ehb-tag--verify-partial { - background: #fff7ed; - color: #c2410c; - border: 1px solid #ffedd5; -} - -.ehb-tag--verify-warn { - background: #fffbeb; - color: #b45309; - border: 1px solid #fde68a; -} - -/* 锚点闪烁高亮 */ -.is-highlight-target { - animation: ehbHighlightPulse 2s ease-out; -} - -@keyframes ehbHighlightPulse { - 0% { - background-color: rgba(59, 130, 246, 0.25); - box-shadow: inset 0 0 0 2px #3b82f6; - } - 100% { - background-color: transparent; - box-shadow: none; - } -} - -/* 总览视角:趋势图表大盘组件样式 */ -.ehb-overview-charts { - display: flex; - flex-direction: column; - gap: 12px; - margin-top: 12px; - margin-bottom: 12px; -} - -.ehb-chart-box { - background: #ffffff; - border-radius: var(--bi-radius); - border: 1px solid var(--bi-hairline); - padding: 16px; -} - -.ehb-chart-box-head { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 16px; -} - -.ehb-chart-box-title { - font-size: 14px; - font-weight: 700; - color: var(--bi-text-body); -} - -.ehb-chart-box-meta { - font-size: 11px; - color: var(--bi-tertiary); -} - -.ehb-chart-legend-inline { - display: flex; - align-items: center; - gap: 14px; -} - -.ehb-chart-legend-tag { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 12px; - font-weight: 500; - color: #475569; -} - -/* 月度收支按业务性质分组:收入(仅对客)与成本(对客/我司/其他)。 */ -.ehb-chart-legend-group { - display: inline-flex; - align-items: center; - gap: 8px; - white-space: nowrap; -} - -.ehb-chart-legend-group > strong { - color: #334155; - font-size: 12px; -} - -.ehb-chart-legend-group.is-cost-group { - padding-left: 12px; - border-left: 1px solid #e2e8f0; -} - -.ehb-legend-sq { - width: 10px; - height: 10px; - border-radius: 2px; -} - -.ehb-legend-sq.is-income { - background: #10b981; -} - -.ehb-legend-sq.is-cost { - background: #f59e0b; -} - -/* 月度加氢量柱状图 */ -.ehb-mbar-chart { - height: 160px; - display: flex; - align-items: flex-end; - gap: 12px; - padding-top: 20px; - padding-bottom: 20px; - border-bottom: 1px solid #f1f5f9; -} - -.ehb-mbar-col { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - height: 100%; - justify-content: flex-end; - position: relative; - cursor: pointer; -} - -.ehb-mbar-val { - position: absolute; - top: -18px; - font-size: 11px; - font-weight: 600; - font-family: var(--bi-font-mono); - color: #475569; - white-space: nowrap; -} - -.ehb-mbar-fill { - width: 100%; - max-width: 42px; - background: linear-gradient(180deg, #38bdf8 0%, #0284c7 100%); - border-radius: 4px 4px 0 0; - transition: all 0.2s ease; -} - -.ehb-mbar-col:hover .ehb-mbar-fill { - filter: brightness(1.1); - transform: scaleY(1.02); -} - -/* 柱状图 Hover 自定义浮动卡片 */ -.ehb-mbar-tooltip { - position: absolute; - bottom: calc(100% + 8px); - left: 50%; - transform: translateX(-50%) translateY(4px); - background: rgba(15, 23, 42, 0.92); - backdrop-filter: blur(8px); - color: #ffffff; - padding: 8px 12px; - border-radius: 6px; - box-shadow: - 0 10px 25px -5px rgba(0, 0, 0, 0.25), - 0 8px 10px -6px rgba(0, 0, 0, 0.2); - font-size: 11px; - white-space: nowrap; - pointer-events: none; - opacity: 0; - visibility: hidden; - transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); - z-index: 20; -} - -.ehb-mbar-col:hover .ehb-mbar-tooltip { - opacity: 1; - visibility: visible; - transform: translateX(-50%) translateY(0); -} - -.ehb-mbar-tooltip__head { - font-weight: 700; - font-size: 11px; - margin-bottom: 4px; - padding-bottom: 4px; - border-bottom: 1px solid rgba(255, 255, 255, 0.15); - color: #f8fafc; -} - -.ehb-mbar-tooltip__row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - line-height: 1.6; -} - -.ehb-mbar-tooltip__left { - display: flex; - align-items: center; - gap: 6px; - color: #cbd5e1; -} - -.ehb-mbar-tooltip__dot { - width: 6px; - height: 6px; - border-radius: 50%; -} - -.ehb-mbar-tooltip__dot.is-own { - background: #38bdf8; -} - -.ehb-mbar-tooltip__dot.is-ext { - background: #f59e0b; -} - -.ehb-mbar-tooltip__val { - font-weight: 700; - font-family: var(--bi-font-mono); - color: #ffffff; -} - -.ehb-mbar-label { - margin-top: 8px; - font-size: 11px; - color: #64748b; - font-family: var(--bi-font-mono); -} - -/* 月度收支对比图 */ -.ehb-rev-chart { - height: 160px; - display: flex; - align-items: flex-end; - gap: 16px; - padding-top: 20px; - padding-bottom: 20px; - border-bottom: 1px solid #f1f5f9; -} - -.ehb-rev-col-group { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - height: 100%; - justify-content: flex-end; -} - -.ehb-rev-bars { - display: flex; - align-items: flex-end; - gap: 4px; - height: 100%; - width: 100%; - justify-content: center; -} - -.ehb-rev-bar-slot { - position: relative; - flex: 0 0 16px; - width: 16px; - height: 100%; -} - -.ehb-rev-bar-slot > .ehb-rev-bar, -.ehb-rev-bar-slot > .ehb-rev-cost-stack { - position: absolute; - bottom: 0; - left: 0; -} - -.ehb-rev-bar { - width: 16px; - border-radius: 3px 3px 0 0; - transition: all 0.2s ease; - position: relative; - cursor: pointer; -} - -/* 月度成本由同一根堆叠柱展示:对客、我司承担、其他。 */ -.ehb-rev-cost-stack { - width: 16px; - min-height: 0; - display: flex; - flex-direction: column-reverse; - overflow: visible; - border-radius: 3px 3px 0 0; -} - -.ehb-rev-cost-segment { - display: block; - width: 100%; - min-height: 2px; - border: 0; - padding: 0; - cursor: pointer; - transition: filter 0.2s ease; -} - -.ehb-rev-cost-segment:hover { - filter: brightness(1.08); -} - -.ehb-rev-cost-segment:last-of-type { - border-radius: 3px 3px 0 0; -} - -.ehb-rev-cost-segment.is-customer { - background: linear-gradient(180deg, #fbbf24 0%, #f59e0b 100%); -} - -.ehb-rev-cost-segment.is-company { - background: linear-gradient(180deg, #60a5fa 0%, #2563eb 100%); -} - -.ehb-rev-cost-segment.is-other { - background: linear-gradient(180deg, #cbd5e1 0%, #94a3b8 100%); -} - -/* 客户收入 Hover 浮层,高保真显示 TOP9 客户 + 其他客户 */ -.ehb-rev-income-tooltip { - position: absolute; - bottom: calc(100% + 8px); - left: 50%; - transform: translateX(-50%) translateY(4px); - background: rgba(15, 23, 42, 0.94); - backdrop-filter: blur(10px); - color: #ffffff; - padding: 10px 14px; - border-radius: 8px; - box-shadow: - 0 12px 30px -5px rgba(0, 0, 0, 0.35), - 0 8px 12px -6px rgba(0, 0, 0, 0.25); - font-size: 11px; - white-space: nowrap; - pointer-events: none; - opacity: 0; - visibility: hidden; - transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); - z-index: 30; - min-width: 270px; -} - -/* 成本支出 Hover 浮层,高保真显示包氢、物流、运维异动等成本项目 */ -.ehb-rev-cost-tooltip { - position: absolute; - bottom: calc(100% + 8px); - left: 50%; - transform: translateX(-50%) translateY(4px); - background: rgba(15, 23, 42, 0.94); - backdrop-filter: blur(10px); - color: #ffffff; - padding: 10px 14px; - border-radius: 8px; - box-shadow: - 0 12px 30px -5px rgba(0, 0, 0, 0.35), - 0 8px 12px -6px rgba(0, 0, 0, 0.25); - font-size: 11px; - white-space: nowrap; - pointer-events: none; - opacity: 0; - visibility: hidden; - transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); - z-index: 30; - min-width: 220px; -} - -/* 避免最边缘的 1月 或 8月 弹窗被裁剪,靠近左侧靠左对齐,靠近右侧靠右对齐 */ -.ehb-rev-col-group:first-child .ehb-rev-income-tooltip, -.ehb-rev-col-group:first-child .ehb-rev-cost-tooltip { - left: 0; - transform: translateX(0) translateY(4px); -} -.ehb-rev-col-group:first-child - .ehb-rev-bar.is-income:hover - .ehb-rev-income-tooltip, -.ehb-rev-col-group:first-child - .ehb-rev-bar.is-cost:hover - .ehb-rev-cost-tooltip { - transform: translateX(0) translateY(0); -} - -.ehb-rev-col-group:last-child .ehb-rev-income-tooltip, -.ehb-rev-col-group:last-child .ehb-rev-cost-tooltip { - left: auto; - right: 0; - transform: translateX(0) translateY(4px); -} -.ehb-rev-col-group:last-child - .ehb-rev-bar.is-income:hover - .ehb-rev-income-tooltip, -.ehb-rev-col-group:last-child .ehb-rev-bar.is-cost:hover .ehb-rev-cost-tooltip { - transform: translateX(0) translateY(0); -} - -.ehb-rev-bar.is-income:hover .ehb-rev-income-tooltip, -.ehb-rev-bar.is-cost:hover .ehb-rev-cost-tooltip { - opacity: 1; - visibility: visible; - transform: translateX(-50%) translateY(0); -} - -.ehb-rev-income-tooltip__head { - display: flex; - align-items: center; - justify-content: space-between; - font-weight: 700; - font-size: 11px; - margin-bottom: 6px; - padding-bottom: 5px; - border-bottom: 1px solid rgba(255, 255, 255, 0.15); - color: #34d399; -} - -.ehb-rev-cost-tooltip__head { - display: flex; - align-items: center; - justify-content: space-between; - font-weight: 700; - font-size: 11px; - margin-bottom: 6px; - padding-bottom: 5px; - border-bottom: 1px solid rgba(255, 255, 255, 0.15); - color: #fbbf24; -} - -.ehb-rev-cost-tooltip__list { - display: flex; - flex-direction: column; - gap: 4px; -} - -.ehb-rev-cost-tooltip__item { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - line-height: 1.5; -} - -.ehb-rev-cost-tooltip__tag { - display: inline-flex; - align-items: center; - gap: 6px; - color: #cbd5e1; - font-weight: 500; -} - -.ehb-rev-cost-tooltip__dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: #f59e0b; -} - -.ehb-rev-cost-tooltip__val { - font-weight: 700; - font-family: var(--bi-font-mono); - color: #ffffff; -} - -.ehb-rev-cost-tooltip__foot { - margin-top: 6px; - padding-top: 5px; - border-top: 1px dashed rgba(255, 255, 255, 0.18); - display: flex; - align-items: center; - justify-content: space-between; - font-weight: 700; -} - -.ehb-rev-income-tooltip__list { - display: flex; - flex-direction: column; - gap: 4px; -} - -.ehb-rev-income-tooltip__item { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - line-height: 1.5; -} - -.ehb-rev-income-tooltip__cust-name { - color: #cbd5e1; - font-weight: 500; - max-width: 175px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.ehb-rev-income-tooltip__cust-name.is-other { - color: #94a3b8; - font-style: italic; -} - -.ehb-rev-income-tooltip__cust-val { - font-weight: 700; - font-family: var(--bi-font-mono); - color: #ffffff; -} - -.ehb-rev-income-tooltip__foot { - margin-top: 6px; - padding-top: 5px; - border-top: 1px dashed rgba(255, 255, 255, 0.18); - display: flex; - align-items: center; - justify-content: space-between; - font-weight: 700; -} - -.ehb-rev-bar.is-cost { - background: linear-gradient(180deg, #fbbf24 0%, #f59e0b 100%); -} - -.ehb-rev-bar.is-income { - background: linear-gradient(180deg, #34d399 0%, #10b981 100%); -} - -.ehb-rev-label { - margin-top: 8px; - font-size: 11px; - color: #64748b; - font-family: var(--bi-font-mono); -} - -/* 两图排布行 */ -.ehb-two-charts-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; -} - -/* 汇总大表卡片 (加氢站加氢汇总 & 客户账单汇总) */ -.ehb-sum-table-card { - background: #ffffff; - border-radius: var(--bi-radius); - border: 1px solid var(--bi-hairline); - padding: 18px; - margin-top: 12px; -} - -.ehb-sum-table-card__head { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 12px; -} - -.ehb-sum-table-card__title { - font-size: 14px; - font-weight: 700; - color: var(--bi-text-body); -} - -.ehb-sum-table-card__meta { - font-size: 11px; - font-weight: 600; - color: #64748b; - font-family: var(--bi-font-mono); -} - -/* 省份溢出项必须以浮层呈现:桌面端不能把菜单按钮当作普通文本继续排版到表格上方。 */ -.ehb-province-tabs { - position: relative; -} - -.ehb-province-tabs__more { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 34px; - padding-inline: 8px !important; -} - -.ehb-province-tabs__menu { - position: absolute; - z-index: 50; - top: calc(100% + 8px); - right: 0; - display: grid; - grid-template-columns: repeat(3, minmax(120px, 1fr)); - gap: 4px; - width: min(480px, calc(100vw - 96px)); - max-height: 300px; - overflow-y: auto; - padding: 8px; - border: 1px solid #dbeafe; - border-radius: 8px; - background: #ffffff; - box-shadow: 0 12px 24px rgba(15, 23, 42, 0.14); -} - -.ehb-province-tabs__menu button { - min-width: 0; - min-height: 34px; - padding: 5px 8px; - overflow: hidden; - border: 0; - border-radius: 5px; - background: #f8fafc; - color: #475569; - font-size: 12px; - font-weight: 600; - line-height: 1.35; - text-align: left; - text-overflow: ellipsis; - white-space: nowrap; - cursor: pointer; -} - -.ehb-province-tabs__menu button:hover, -.ehb-province-tabs__menu button:focus-visible { - outline: none; - background: #eff6ff; - color: #0284c7; -} - -.ehb-province-tabs__menu button.is-active { - background: #e0f2fe; - color: #0284c7; -} - -.ehb-sum-table-wrap { - width: 100%; - overflow-x: auto; -} - -.ehb-sum-table { - width: 100%; - border-collapse: collapse; - text-align: left; -} - -.ehb-sum-table th { - font-size: 11px; - font-weight: 600; - color: #64748b; - padding: 10px 12px; - border-bottom: 1px solid #f1f5f9; - white-space: nowrap; -} - -.ehb-sum-table td { - font-size: 12px; - color: #334155; - padding: 10px 12px; - border-bottom: 1px solid #f8fafc; - white-space: nowrap; -} - -.ehb-sum-table tr:hover td { - background-color: #f8fafc; -} - -.ehb-sum-table .col-idx { - width: 40px; - text-align: center; - color: #94a3b8; - font-family: var(--bi-font-mono); - font-size: 11px; -} - -.ehb-sum-table .col-bold-kg { - font-weight: 700; - font-family: var(--bi-font-mono); - color: #0f172a; -} - -.ehb-sum-table .col-green-fee { - font-weight: 700; - color: #10b981; - font-family: var(--bi-font-mono); -} - -.ehb-sum-table .col-orange-cost { - font-weight: 700; - color: #f59e0b; - font-family: var(--bi-font-mono); -} - -.ehb-stay-tuned-tag { - display: inline-block; - font-size: 11px; - font-weight: 500; - color: #64748b; - background: #f1f5f9; - border: 1px dashed #cbd5e1; - border-radius: 4px; - padding: 1px 6px; - cursor: help; - transition: all 0.2s ease; -} - -.ehb-stay-tuned-tag:hover { - color: #533afd; - background: #f0f0ff; - border-color: #a5b4fc; -} - -/* KPI 数据来源穿透 Modal 弹窗 */ -.ehb-modal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(15, 23, 42, 0.7); - backdrop-filter: blur(8px); - z-index: 1000; - display: flex; - align-items: center; - justify-content: center; - padding: 20px; - animation: ehbFadeIn 0.2s cubic-bezier(0.16, 1, 0.3, 1); -} - -@keyframes ehbFadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -.ehb-modal-card { - background: #ffffff; - border-radius: 12px; - box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.35); - width: 100%; - max-width: 1100px; - max-height: 90vh; - display: flex; - flex-direction: column; - overflow: hidden; - border: 1px solid rgba(226, 232, 240, 0.8); - animation: ehbSlideUp 0.25s cubic-bezier(0.16, 1, 0.3, 1); -} - -@keyframes ehbSlideUp { - from { - opacity: 0; - transform: translateY(16px) scale(0.98); - } - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -.ehb-modal-head { - padding: 16px 20px; - background: #0f172a; - color: #ffffff; - display: flex; - align-items: center; - justify-content: space-between; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - /* 标题是下钻路径和当前统计范围的锚点,不能随明细滚出视区。 */ - position: sticky; - top: 0; - z-index: 4; - flex-shrink: 0; -} - -.ehb-modal-head__title-group { - display: flex; - align-items: center; - gap: 10px; -} - -.ehb-modal-head__title { - font-size: 16px; - font-weight: 700; - color: #f8fafc; - display: flex; - align-items: center; - gap: 8px; -} - -.ehb-modal-head__sub { - font-size: 12px; - color: #94a3b8; - margin-top: 2px; -} - -.ehb-modal-back-btn { - display: inline-flex; - align-items: center; - gap: 4px; - background: rgba(255, 255, 255, 0.12); - border: 1px solid rgba(255, 255, 255, 0.2); - color: #f8fafc; - padding: 5px 10px; - border-radius: 8px; - font-size: 13px; - font-weight: 600; - cursor: pointer; - margin-right: 8px; - transition: all 0.2s ease; - flex-shrink: 0; -} - -.ehb-modal-back-btn:hover { - background: rgba(56, 189, 248, 0.2); - border-color: #38bdf8; - color: #38bdf8; -} - -.ehb-modal-head__actions { - display: flex; - align-items: center; - gap: 12px; -} - -.ehb-modal-close-btn { - background: rgba(255, 255, 255, 0.1); - border: none; - color: #cbd5e1; - width: 32px; - height: 32px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all 0.2s; -} - -.ehb-modal-close-btn:hover { - background: rgba(239, 68, 68, 0.8); - color: #ffffff; -} - -.ehb-modal-body { - padding: 20px; - overflow-y: auto; - flex: 1; - background: #f8fafc; -} - -.ehb-modal-meta-bar { - background: #ffffff; - border-radius: 8px; - border: 1px solid #e2e8f0; - padding: 14px 18px; - margin-bottom: 16px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - flex-wrap: wrap; -} - -.ehb-modal-meta-item { - display: flex; - flex-direction: column; - gap: 2px; -} - -.ehb-modal-meta-label { - font-size: 11px; - color: #64748b; -} - -.ehb-modal-meta-val { - font-size: 16px; - font-weight: 800; - color: #0f172a; - font-family: var(--bi-font-mono); -} - -.ehb-modal-filter-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 16px; - background: #ffffff; - padding: 10px 14px; - border-radius: 8px; - border: 1px solid #e2e8f0; - flex-wrap: wrap; -} - -.ehb-modal-filter-group { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; -} - -/* 穿透筛弱提示:灰色小字 */ -.ehb-modal-hint-text { - font-size: 11px; - font-weight: 400; - color: var(--bi-tertiary, #94a3b8); - line-height: 1.4; - margin: 0; -} - -.ehb-modal-filter-group > .ehb-modal-hint-text { - flex: 1; - min-width: 180px; -} - -.ehb-modal-hint-text strong { - color: inherit; - font-weight: 400; -} - -.ehb-order-more-row { - display: inline-flex; - align-items: center; - flex-wrap: wrap; - gap: 8px; -} - -.ehb-order-more-btn { - color: #0284c7; - cursor: pointer; - border: 1px solid #bae6fd; - background: #f0f9ff; - padding: 2px 8px; - border-radius: 4px; - font-size: 11px; - font-weight: 600; - font-family: inherit; - flex-shrink: 0; -} - -.ehb-order-more-btn:hover { - background: #e0f2fe; - border-color: #7dd3fc; -} - -.ehb-order-more-hint { - font-size: 11px; - font-weight: 400; - color: var(--bi-tertiary, #94a3b8); - line-height: 1.4; -} - -.ehb-modal-search-input { - display: inline-flex; - align-items: center; - gap: 6px; - background: #ffffff; - border: 1px solid #cbd5e1; - border-radius: 6px; - padding: 4px 10px; - height: 32px; - font-size: 12px; - width: 220px; - transition: all 0.2s; - box-sizing: border-box; -} - -.ehb-modal-search-input:focus-within { - border-color: #0284c7; - box-shadow: 0 0 0 2px rgba(2, 132, 199, 0.15); -} - -.ehb-modal-search-input input { - border: none !important; - outline: none !important; - background: transparent !important; - flex: 1; - min-width: 0; - padding: 0 !important; - margin: 0 !important; - font-size: 12px; - color: #0f172a; - font-family: inherit; - box-shadow: none !important; -} - -.ehb-modal-search-input button { - border: none; - background: transparent; - padding: 0; - margin: 0; - color: #94a3b8; - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; -} - -.ehb-modal-search-input button:hover { - color: #ef4444; -} - -.ehb-modal-select { - border: 1px solid #cbd5e1; - border-radius: 6px; - padding: 0 8px; - height: 32px; - font-size: 12px; - color: #334155; - background: #ffffff; - min-width: 140px; - outline: none; - cursor: pointer; - transition: all 0.2s; - box-sizing: border-box; -} - -/* BI 可搜索选择器(穿透筛 · 非 V2) */ -.ehb-bi-search-select { - position: relative; - flex-shrink: 0; -} - -.ehb-bi-search-select.is-disabled { - opacity: 0.55; - pointer-events: none; -} - -.ehb-bi-search-select__trigger { - display: inline-flex; - align-items: center; - justify-content: space-between; - gap: 6px; - width: 100%; - height: 32px; - padding: 0 10px; - border: 1px solid #cbd5e1; - border-radius: 6px; - background: #fff; - font-size: 12px; - color: #64748b; - cursor: pointer; - box-sizing: border-box; -} - -.ehb-bi-search-select__trigger.has-value { - color: #0f172a; -} - -.ehb-bi-search-select__trigger.is-open, -.ehb-bi-search-select__trigger:hover { - border-color: #0284c7; -} - -.ehb-bi-search-select__trigger.is-open { - box-shadow: 0 0 0 2px rgba(2, 132, 199, 0.15); -} - -.ehb-bi-search-select__label { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - text-align: left; - flex: 1; - min-width: 0; -} - -.ehb-bi-search-select__chevron { - flex-shrink: 0; - color: #64748b; -} - -.ehb-bi-search-select__dropdown { - position: absolute; - top: calc(100% + 4px); - left: 0; - right: 0; - min-width: 100%; - z-index: 40; - background: #fff; - border: 1px solid #e2e8f0; - border-radius: 8px; - box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12); - overflow: hidden; -} - -.ehb-bi-search-select__search { - display: flex; - align-items: center; - gap: 6px; - padding: 8px 10px; - border-bottom: 1px solid #e2e8f0; - color: #94a3b8; -} - -.ehb-bi-search-select__search input { - flex: 1; - min-width: 0; - border: none !important; - outline: none !important; - background: transparent !important; - box-shadow: none !important; - font-size: 12px; - color: #0f172a; - padding: 0 !important; - margin: 0 !important; - font-family: inherit; -} - -.ehb-bi-search-select__list { - max-height: 220px; - overflow-y: auto; - padding: 4px; -} - -.ehb-bi-search-select__item { - display: block; - width: 100%; - text-align: left; - border: none; - background: transparent; - padding: 8px 10px; - border-radius: 6px; - font-size: 12px; - color: #334155; - cursor: pointer; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.ehb-bi-search-select__item:hover { - background: #f1f5f9; -} - -.ehb-bi-search-select__item.is-selected { - background: #e0f2fe; - color: #0369a1; - font-weight: 600; -} - -.ehb-bi-search-select__empty { - padding: 12px 10px; - font-size: 12px; - color: #94a3b8; - text-align: center; -} - -.ehb-modal-select:focus { - border-color: #0284c7; - box-shadow: 0 0 0 2px rgba(2, 132, 199, 0.15); -} - -/* Modal 内可钻取 Tree Table */ -.ehb-modal-table-wrap { - background: #ffffff; - border-radius: 8px; - border: 1px solid #e2e8f0; - overflow: hidden; -} - -.ehb-modal-table-wrap.is-v-scroll { - max-height: min(52vh, 440px); - overflow-y: auto; -} - -/* 下钻明细仅在表格容器内滚动:保留列标题,避免长表滚动后失去指标语义。 */ -.ehb-modal-table-wrap.is-v-scroll .ehb-modal-table thead th { - position: sticky; - top: 0; - z-index: 3; - background: #f1f5f9; -} - -.ehb-modal-table { - width: 100%; - border-collapse: collapse; - text-align: left; -} - -.ehb-modal-table th { - background: #f1f5f9; - font-size: 11px; - font-weight: 700; - color: #475569; - padding: 10px 12px; - border-bottom: 1px solid #e2e8f0; - white-space: nowrap; -} - -.ehb-modal-table td { - padding: 10px 12px; - font-size: 12px; - border-bottom: 1px solid #f1f5f9; - white-space: nowrap; -} - -/* Modal 树形表格层级与 H5 换行多行适配 */ -.ehb-modal-table th:first-child, -.ehb-modal-table td:first-child { - min-width: 240px; -} - -.ehb-tree-node-title { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 4px 6px; - line-height: 1.4; -} - -.ehb-tree-node-sub { - font-size: 11px; - color: #64748b; - font-weight: 400; - white-space: normal; -} - -.ehb-tree-ord-block { - display: flex; - flex-direction: column; - gap: 2px; - line-height: 1.3; -} - -.ehb-tree-ord-time { - font-size: 10px; - color: #64748b; - font-weight: 400; -} - -.ehb-tree-cell-l1 { - padding-left: 12px; -} -.ehb-tree-cell-l2 { - padding-left: 28px; -} -.ehb-tree-cell-l3 { - padding-left: 44px; -} -.ehb-tree-cell-l4 { - padding-left: 60px; -} - -.ehb-modal-table tr:hover td { - background-color: #f8fafc; -} - -/* KPI 点击下钻提示图标/按钮 */ -.ehb-kpi-drill-hint { - font-size: 10px; - color: var(--oneos-primary, #533afd); - background: rgba(83, 58, 253, 0.08); - padding: 2px 6px; - border-radius: 4px; - font-weight: 600; - margin-left: 6px; - display: inline-flex; - align-items: center; - gap: 2px; - transition: all 0.2s ease; -} - -.ehb-kpi-dual:hover .ehb-kpi-drill-hint { - background: #533afd; - color: #ffffff; -} - -.ehb-kpi-dual { - cursor: pointer; - transition: - transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), - box-shadow 0.2s cubic-bezier(0.16, 1, 0.3, 1); -} - -.ehb-kpi-dual:hover { - transform: translateY(-2px); - box-shadow: 0 8px 20px -2px rgba(83, 58, 253, 0.15); -} - -/* 迷你比例进度条 */ -.ehb-ratio-flex { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 10px; -} - -.ehb-mini-bar-track { - width: 60px; - height: 5px; - background: #f1f5f9; - border-radius: 3px; - overflow: hidden; - position: relative; -} - -.ehb-mini-bar-fill { - height: 100%; - border-radius: 3px; -} - -.ehb-mini-bar-fill.is-blue { - background: #0284c7; -} - -.ehb-mini-bar-fill.is-green { - background: #10b981; -} - -.ehb-ratio-text { - font-size: 11px; - font-family: var(--bi-font-mono); - color: #475569; - min-width: 42px; - text-align: right; -} - -/* 承担方 Badge */ -.ehb-bearer-tag { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 1px 8px; - border-radius: 4px; - font-size: 11px; - font-weight: 600; -} - -.ehb-bearer-tag.is-cust { - color: #d97706; - background: #fffbe3; - border: 1px solid #fde68a; -} - -.ehb-bearer-tag.is-lingniu { - color: #2563eb; - background: #eff6ff; - border: 1px solid #bfdbfe; -} - -.ehb-bearer-tag.is-both { - color: #7c3aed; - background: #f5f3ff; - border: 1px solid #ddd6fe; -} - -/* Top5 站条形图 */ -.ehb-top-stations-list { - display: flex; - flex-direction: column; - gap: 12px; -} - -.ehb-top-station-item { - display: flex; - align-items: center; - gap: 10px; -} - -.ehb-top-rank { - display: flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - border-radius: 50%; - background: #0284c7; - color: #ffffff; - font-size: 11px; - font-weight: 700; - font-family: var(--bi-font-mono); - flex-shrink: 0; -} - -.ehb-top-rank.is-sub { - background: #94a3b8; -} - -.ehb-top-station-name { - font-size: 12px; - font-weight: 600; - color: #1e293b; - width: 150px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - flex-shrink: 0; -} - -.ehb-top-bar-bg { - flex: 1; - height: 12px; - background: #f1f5f9; - border-radius: 6px; - overflow: visible; - position: relative; -} - -.ehb-top-bar-fill { - height: 100%; - display: flex; - overflow: hidden; - border-radius: 6px; - transition: width 0.3s ease; - position: relative; -} - -.ehb-top-bar-seg { - height: 100%; - transition: width 0.2s ease; -} - -.ehb-top-bar-seg.is-own { - background: linear-gradient(90deg, #38bdf8 0%, #0284c7 100%); -} - -.ehb-top-bar-seg.is-ext { - background: linear-gradient(90deg, #fbbf24 0%, #f59e0b 100%); -} - -/* Top5 站加氢量横向 Hover 自定义悬浮卡 */ -.ehb-top-bar-tooltip { - position: absolute; - bottom: calc(100% + 8px); - left: 50%; - transform: translateX(-50%) translateY(4px); - background: rgba(15, 23, 42, 0.94); - backdrop-filter: blur(10px); - color: #ffffff; - padding: 8px 12px; - border-radius: 6px; - box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.35); - font-size: 11px; - white-space: nowrap; - pointer-events: none; - opacity: 0; - visibility: hidden; - transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); - z-index: 30; - min-width: 210px; -} - -.ehb-top-bar-bg:hover .ehb-top-bar-tooltip, -.ehb-top-station-item:hover .ehb-top-bar-tooltip { - opacity: 1; - visibility: visible; - transform: translateX(-50%) translateY(0); -} - -.ehb-top-bar-tooltip__head { - font-weight: 700; - color: #38bdf8; - font-size: 11px; - margin-bottom: 4px; - padding-bottom: 4px; - border-bottom: 1px solid rgba(255, 255, 255, 0.15); -} - -.ehb-top-bar-tooltip__row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - line-height: 1.6; -} - -.ehb-top-bar-tooltip__left { - display: flex; - align-items: center; - gap: 6px; - color: #cbd5e1; -} - -.ehb-top-bar-tooltip__dot { - width: 6px; - height: 6px; - border-radius: 50%; -} - -.ehb-top-bar-tooltip__dot.is-own { - background: #38bdf8; -} - -.ehb-top-bar-tooltip__dot.is-ext { - background: #f59e0b; -} - -.ehb-top-bar-tooltip__val { - font-weight: 700; - font-family: var(--bi-font-mono); - color: #ffffff; -} - -.ehb-top-bar-tooltip__foot { - margin-top: 4px; - padding-top: 4px; - border-top: 1px dashed rgba(255, 255, 255, 0.15); - display: flex; - align-items: center; - justify-content: space-between; - color: #cbd5e1; - font-weight: 700; -} - -.ehb-top-station-val { - font-size: 12px; - font-weight: 700; - font-family: var(--bi-font-mono); - color: #0f172a; - width: 70px; - text-align: right; - flex-shrink: 0; -} - -.ehb-top-station-val__mobile { - display: none; -} - -/* 迷你切换分段页签 (如:按省 / 按市) */ -.ehb-mini-tabs { - display: inline-flex; - align-items: center; - background: #f1f5f9; - border-radius: 6px; - padding: 2px; - gap: 2px; -} - -.ehb-mini-tab { - border: none; - background: transparent; - padding: 2px 10px; - font-size: 11px; - font-weight: 500; - color: #64748b; - border-radius: 4px; - cursor: pointer; - transition: all 0.2s ease; -} - -.ehb-mini-tab.is-active { - background: #ffffff; - color: #0284c7; - font-weight: 700; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); -} - -/* 区域占比 Donut */ -.ehb-donut-section { - display: flex; - align-items: flex-start; - gap: 20px; -} - -.ehb-donut-chart-wrap { - position: relative; - width: 130px; - height: 130px; - flex-shrink: 0; -} - -.ehb-donut-center-text { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - text-align: center; -} - -.ehb-donut-center-text .title { - font-size: 10px; - color: #64748b; -} - -.ehb-donut-center-text .val { - font-size: 13px; - font-weight: 800; - color: #0f172a; - font-family: var(--bi-font-mono); -} - -.ehb-region-legend-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px 16px; - width: 100%; -} - -.ehb-region-legend-area { - flex: 1 1 0; - min-width: 0; - display: flex; - flex-direction: column; - gap: 8px; -} - -.ehb-region-legend-item { - display: flex; - align-items: center; - justify-content: space-between; - min-width: 0; - width: 100%; - border: 0; - padding: 0; - background: transparent; - cursor: pointer; - font-size: 11px; -} - -.ehb-region-legend-left { - display: flex; - align-items: center; - gap: 6px; - min-width: 0; - flex: 1; - color: #334155; -} - -.ehb-region-legend-left > span:last-child { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.ehb-region-dot { - width: 7px; - height: 7px; - border-radius: 50%; - flex-shrink: 0; -} - -.ehb-region-legend-val { - flex-shrink: 0; - margin-left: 8px; - font-weight: 700; - font-family: var(--bi-font-mono); - color: #0f172a; -} - -@media (max-width: 1100px) { - .ehb-two-charts-row { - grid-template-columns: 1fr; - } -} - -/* 最终桌面覆盖:承担金额不截断。 */ -@media (min-width: 768px) { - .ehb-host-kpi .ehb-kpi-dual__deck.is-triple { - display: grid !important; - grid-template-columns: repeat(3, minmax(0, 1fr)) !important; - gap: 0 !important; - } - .ehb-host-kpi .ehb-kpi-dual__deck.is-triple .ehb-kpi-dual__detail { - display: flex !important; - min-width: 0 !important; - flex-direction: column !important; - align-items: flex-start !important; - padding-inline: 7px !important; - overflow: visible !important; - } - .ehb-host-kpi .ehb-kpi-dual__deck.is-triple .ehb-kpi-dual__detail:first-child { padding-left: 0 !important; } - .ehb-host-kpi .ehb-kpi-dual__deck.is-triple .ehb-kpi-dual__detail:last-child { padding-right: 0 !important; } - .ehb-host-kpi .ehb-kpi-dual__detail-label, - .ehb-host-kpi .ehb-kpi-dual__detail-value { - max-width: none !important; - overflow: visible !important; - text-overflow: clip !important; - white-space: nowrap !important; - } - .ehb-host-kpi .ehb-kpi-dual__detail-value { - font-size: 10px !important; - letter-spacing: -0.04em !important; - } -} - -/* Runtime data tree: use the prototype's table hierarchy while retaining real - source/verification fields and a bounded first render for long query results. */ -.ehb-daily-tree-toggle { - display: inline-flex; - width: 16px; - margin-right: 7px; - color: #0284c7; - font-size: 11px; - font-weight: 800; - justify-content: center; -} -.ehb-daily-tree-toggle.is-station { color: #059669; } -.ehb-daily-tree-toggle.is-customer { color: #0284c7; } -.ehb-daily-tree-branch { - display: inline-block; - margin-right: 7px; - color: #94a3b8; - font-family: var(--bi-font-mono); -} -.ehb-daily-record-tag { - display: inline-flex; - margin-left: 6px; - padding: 2px 6px; - border-radius: 4px; - font-size: 11px; - font-weight: 700; - line-height: 1.35; -} -.ehb-daily-record-tag.is-own { color: #15803d; background: #dcfce7; } -.ehb-daily-record-tag.is-external { color: #c2410c; background: #ffedd5; } -.ehb-daily-record-tag.is-source { color: #0369a1; background: #e0f2fe; } -.ehb-daily-record-tag.is-verified { color: #047857; background: #d1fae5; } -.ehb-daily-record-tag.is-unverified { color: #b45309; background: #fef3c7; } -.ehb-daily-tree-more-row td { - padding: 6px 12px !important; - text-align: center; - background: #f8fafc; -} -.ehb-daily-tree-more-btn { - border: 1px dashed #bfdbfe; - border-radius: 5px; - padding: 4px 10px; - background: #fff; - color: #0284c7; - font-size: 12px; - font-weight: 600; - cursor: pointer; -} -.ehb-daily-tree-more-btn:hover { background: #eff6ff; } - -@media (max-width: 767px) { - .ehb-daily-tree-toggle { margin-right: 4px; } - .ehb-daily-record-tag { margin-left: 3px; padding: 2px 4px; font-size: 10px; } -} - -/* Runtime: keep long query-result tables compact until the user asks for more. */ -.ehb-list-more-btn { - display: inline-flex; - width: calc(100% - 32px); - min-height: 34px; - margin: 10px 16px 14px; - align-items: center; - justify-content: center; - gap: 6px; - border: 1px dashed #cbd5e1; - border-radius: 8px; - background: #f8fafc; - color: #475569; - font-size: 12px; - font-weight: 600; - cursor: pointer; -} -.ehb-list-more-btn:hover { - border-color: #93c5fd; - background: #eff6ff; - color: #0284c7; -} - -.ehb-region-more-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - width: 100%; - min-height: 32px; - margin-top: 8px; - border: 1px dashed #cbd5e1; - border-radius: 8px; - background: #f8fafc; - color: #475569; - font-size: 12px; - font-weight: 600; - cursor: pointer; -} -.ehb-region-more-btn:hover { - border-color: #93c5fd; - background: #eff6ff; - color: #0284c7; -} - -@media (max-width: 767px) { - .ehb-sum-table-card__head .ehb-mini-tabs { - display: flex !important; - width: 100%; - max-width: none !important; - flex-wrap: nowrap !important; - overflow-x: auto !important; - overflow-y: hidden; - -webkit-overflow-scrolling: touch; - } - .ehb-sum-table-card__head .ehb-mini-tab { - flex: 0 0 auto; - white-space: nowrap; - min-height: 36px; - } -} - -.ehb-h5-scroll-hint { - display: none; -} - -.ehb-chart-scroll-hint { - display: none; -} - -@media (max-width: 767px) { - .ehb-shell { - display: block; - width: 100%; - max-width: 100%; - overflow-x: clip; - } - - .ehb-rail { - display: none; - } - .ehb-body { - padding: 10px 10px 24px; - width: 100%; - max-width: 100%; - overflow-x: clip; - } - .ehb-chrome { - margin: -6px -6px 10px; - padding: 8px 10px; - } - .ehb-chrome__lead { - flex-direction: column; - align-items: flex-start !important; - gap: 6px !important; - } - .ehb-chrome__lead h1 { - font-size: 17px; - } - .ehb-time-range-pill { - font-size: 11px !important; - padding: 3px 8px !important; - } - .ehb-daily-kpi-grid, - .ehb-host-kpi { - display: grid !important; - grid-template-columns: repeat(2, minmax(0, 1fr)) !important; - gap: 8px !important; - margin-bottom: 12px !important; - } - - .ehb-host, - .ehb-daily-kpi-grid, - .ehb-host-kpi { - min-width: 0 !important; - } - - .ehb-daily-kpi-card, - .ehb-kpi-dual { - padding: 10px 10px !important; - box-sizing: border-box !important; - min-width: 0 !important; - } - - .ehb-daily-kpi-head, - .ehb-kpi-dual__head { - margin-bottom: 4px !important; - } - - .ehb-daily-kpi-title, - .ehb-kpi-dual__label { - font-size: 11px !important; - white-space: nowrap !important; - overflow: hidden !important; - text-overflow: ellipsis !important; - } - - .ehb-kpi-dual__num { - font-size: 18px !important; - line-height: 1.2 !important; - font-weight: 700 !important; - } - - .ehb-kpi-dual__unit { - font-size: 11px !important; - margin-left: 2px !important; - } - - .ehb-daily-kpi-sub, - .ehb-kpi-dual__footer { - font-size: 10px !important; - line-height: 1.3 !important; - color: #64748b !important; - word-break: break-all !important; - margin-top: 4px !important; - } - - .ehb-insight { - grid-template-columns: 1fr !important; - } - .ehb-filters__rule { - display: none; - } - - /* 下钻头部仅保留返回/关闭,按日导出仍必须可用。 */ - .ehb-modal-head__actions .ehb-btn { - display: none !important; - } - - /* 1. H5 过滤卡片与按键整齐防错行 */ - .ehb-daily-filter-card { - padding: 10px !important; - min-width: 0 !important; - } - .ehb-daily-filter-row { - flex-direction: column !important; - align-items: stretch !important; - gap: 10px !important; - } - .ehb-daily-filter-group { - flex-wrap: wrap !important; - gap: 8px !important; - width: 100% !important; - justify-content: space-between !important; - } - .ehb-fleet-segmented { - width: 100% !important; - display: flex !important; - box-sizing: border-box !important; - } - .ehb-fleet-btn { - flex: 1 !important; - min-width: 0 !important; - justify-content: center !important; - text-align: center !important; - padding: 0 4px !important; - font-size: 12px !important; - height: 36px !important; - min-height: 36px !important; - white-space: nowrap !important; - } - .ehb-fleet-btn svg { - display: none !important; /* H5 隐藏车辆 Icon 腾出空间防字折断 */ - } - .ehb-pill-tabs { - width: 100% !important; - display: flex !important; - box-sizing: border-box !important; - } - .ehb-pill-btn { - flex: 1 !important; - min-width: 0 !important; - justify-content: center !important; - text-align: center !important; - padding: 0 4px !important; - font-size: 12px !important; - height: 36px !important; - min-height: 36px !important; - white-space: nowrap !important; - } - .ehb-year-select-wrapper { - width: 100% !important; - } - .ehb-year-btn { - width: 100% !important; - height: 36px !important; - justify-content: space-between !important; - } - .ehb-dp-trigger { - height: 36px !important; - padding: 0 8px !important; - font-size: 12px !important; - flex: 1 !important; - } - - /* 2. H5 Modal 下钻全屏沉浸 */ - .ehb-modal-overlay { - padding: 0 !important; - align-items: flex-start !important; - justify-content: flex-start !important; - z-index: 9999 !important; - top: 0 !important; - left: 0 !important; - right: 0 !important; - bottom: 0 !important; - overflow: hidden !important; - } - - .ehb-modal-card { - width: 100vw !important; - max-width: 100vw !important; - height: 100% !important; - height: 100dvh !important; - max-height: 100dvh !important; - border-radius: 0 !important; - border: none !important; - box-shadow: none !important; - display: flex !important; - flex-direction: column !important; - } - - .ehb-modal-head { - padding-top: max(10px, env(safe-area-inset-top, 10px)) !important; - padding-bottom: 10px !important; - padding-left: 12px !important; - padding-right: 12px !important; - min-height: 52px !important; - background: #0f172a !important; - box-sizing: border-box !important; - flex-shrink: 0 !important; - } - - .ehb-modal-head__title-group { - display: flex !important; - align-items: center !important; - gap: 8px !important; - flex: 1 !important; - min-width: 0 !important; - } - - .ehb-modal-head__title-group > div { - flex: 1 !important; - min-width: 0 !important; - } - - .ehb-modal-head__title { - font-size: 13px !important; - line-height: 1.3 !important; - font-weight: 700 !important; - white-space: nowrap !important; - overflow: hidden !important; - text-overflow: ellipsis !important; - } - - .ehb-modal-head__sub { - font-size: 10px !important; - color: #94a3b8 !important; - white-space: nowrap !important; - overflow: hidden !important; - text-overflow: ellipsis !important; - margin-top: 2px !important; - display: block !important; - -webkit-line-clamp: unset !important; - } - - .ehb-modal-back-btn { - padding: 4px 8px !important; - font-size: 12px !important; - min-height: 32px !important; - height: 32px !important; - flex-shrink: 0 !important; - } - - .ehb-modal-body { - padding: 10px 10px 20px !important; - } - - .ehb-modal-meta-bar { - grid-template-columns: repeat(2, 1fr) !important; - gap: 6px !important; - padding: 8px !important; - } - - .ehb-modal-meta-item { - padding: 4px 6px !important; - } - - .ehb-modal-meta-label { - font-size: 10px !important; - } - - .ehb-modal-meta-val { - font-size: 13px !important; - } - - .ehb-modal-filter-row { - flex-direction: column !important; - align-items: stretch !important; - gap: 8px !important; - padding: 8px !important; - } - - .ehb-modal-filter-group { - width: 100% !important; - gap: 8px !important; - } - - .ehb-modal-filter-group select { - flex: 1 !important; - min-width: 0 !important; - } - - /* 3. 统一 H5 控件高度 36px,弱化提示卡占空间 */ - .ehb-modal-search-input, - .ehb-modal-select, - .ehb-bi-search-select, - .ehb-bi-search-select__trigger { - width: 100% !important; - height: 36px !important; - min-height: 36px !important; - font-size: 12px !important; - box-sizing: border-box !important; - } - - .ehb-modal-hint-text { - font-size: 11px !important; - font-weight: 400 !important; - color: var(--bi-tertiary, #94a3b8) !important; - line-height: 1.4 !important; - margin: 2px 0 !important; - } - - .ehb-modal-hint-text strong { - color: inherit !important; - font-weight: 400 !important; - } - - /* 钻取表 100% 支撑横滚与右侧财务/状态列可见,第一列支持多行(2-3行)无缝自适应 */ - .ehb-modal-table-wrap { - width: 100% !important; - overflow-x: auto !important; - -webkit-overflow-scrolling: touch !important; - display: block !important; - border-radius: 8px !important; - border: 1px solid #e2e8f0 !important; - box-shadow: inset -6px 0 8px -4px rgba(15, 23, 42, 0.1) !important; - } - - .ehb-modal-table { - min-width: 820px !important; - table-layout: auto !important; - } - - .ehb-modal-table th, - .ehb-modal-table td { - padding: 8px 8px !important; - font-size: 11px !important; - white-space: normal !important; - word-break: break-word !important; - } - - .ehb-modal-table th:first-child, - .ehb-modal-table td:first-child { - min-width: 250px !important; - } - - /* H5 移动端紧凑树结构缩进 */ - .ehb-tree-cell-l1 { - padding-left: 6px !important; - } - .ehb-tree-cell-l2 { - padding-left: 16px !important; - } - .ehb-tree-cell-l3 { - padding-left: 26px !important; - } - .ehb-tree-cell-l4 { - padding-left: 36px !important; - } - - .ehb-h5-scroll-hint { - display: block !important; - font-size: 11px !important; - color: #0284c7 !important; - background: rgba(2, 132, 199, 0.08) !important; - padding: 4px 8px !important; - border-radius: 4px !important; - margin-bottom: 6px !important; - text-align: center !important; - font-weight: 500 !important; - } - - /* H5 下日期 Popover 与年份 Select 转 Bottom Sheet */ - .ehb-date-popover, - .ehb-year-dropdown { - position: fixed !important; - bottom: 0 !important; - left: 0 !important; - right: 0 !important; - top: auto !important; - width: 100vw !important; - max-width: 100vw !important; - border-radius: 16px 16px 0 0 !important; - box-shadow: 0 -10px 30px rgba(15, 23, 42, 0.3) !important; - z-index: 10000 !important; - animation: ehbSlideUpSheet 0.25s cubic-bezier(0.16, 1, 0.3, 1) !important; - } - - @keyframes ehbSlideUpSheet { - from { - transform: translateY(100%); - } - to { - transform: translateY(0); - } - } - - /* 图表头部与图例 H5 上下分行两端对齐,彻底消除图例重叠挤压 */ - .ehb-daily-chart-head, - .ehb-chart-box-head { - flex-direction: column !important; - align-items: flex-start !important; - gap: 8px !important; - margin-bottom: 10px !important; - } - - .ehb-daily-chart-title, - .ehb-chart-box-title { - width: 100% !important; - } - - .ehb-daily-chart-meta-group { - width: 100% !important; - display: flex !important; - align-items: center !important; - justify-content: space-between !important; - gap: 8px !important; - } - - .ehb-daily-chart-legend, - .ehb-chart-legend-inline { - display: grid !important; - grid-template-columns: max-content max-content; - align-items: center !important; - justify-content: start !important; - width: 100% !important; - gap: 8px 14px !important; - } - - .ehb-legend-item, - .ehb-chart-legend-tag { - font-size: 12px !important; - white-space: nowrap !important; - } - - .ehb-daily-chart-meta, - .ehb-chart-box-meta { - grid-column: 1 / -1; - width: 100%; - font-size: 11px !important; - color: #64748b !important; - line-height: 1.5; - white-space: normal !important; - } - - /* 图表与表格在 H5 下的自适应 */ - .ehb-daily-bar-container { - overflow-x: auto !important; - overflow-y: hidden !important; - -webkit-overflow-scrolling: touch !important; - padding-top: 28px !important; - padding-bottom: 24px !important; - gap: 10px !important; - } - - .ehb-daily-bar-col { - flex: 0 0 46px !important; - min-width: 46px !important; - max-width: 46px !important; - } - - .ehb-daily-bar-fill { - width: 24px !important; - max-width: 24px !important; - } - - .ehb-daily-bar-val { - font-size: 11px !important; - font-weight: 600 !important; - top: -22px !important; - } - - .ehb-daily-bar-label { - font-size: 11px !important; - margin-top: 6px !important; - white-space: nowrap !important; - } - - .ehb-mbar-chart, - .ehb-rev-chart { - overflow-x: auto !important; - -webkit-overflow-scrolling: touch !important; - padding-bottom: 6px !important; - } - - .ehb-chart-scroll-hint { - display: block !important; - margin: -2px 0 4px !important; - color: #64748b !important; - font-size: 10px !important; - line-height: 1.4 !important; - text-align: right !important; - } - - .ehb-mbar-col { - min-width: 42px !important; - } - - .ehb-rev-col-group { - min-width: 52px !important; - } - - .ehb-donut-section { - flex-direction: column !important; - align-items: stretch !important; - gap: 14px !important; - } - - .ehb-donut-chart-wrap { - margin: 0 auto !important; - } - - .ehb-region-legend-grid { - /* 移动端不再沿用桌面双列:圆环下方按单列对齐,避免两列名/值互相挤压。 */ - grid-template-columns: minmax(0, 1fr) !important; - width: 100% !important; - gap: 0 !important; - } - - .ehb-region-legend-item { - min-height: 40px; - padding: 8px 4px; - border-bottom: 1px solid #f1f5f9; - } - - .ehb-region-legend-left { - min-width: 0; - overflow: hidden; - } - - .ehb-region-legend-left span:last-child { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .ehb-region-legend-val { - min-width: 48px; - text-align: right; - } - - .ehb-sum-table-card__head { - flex-direction: column !important; - align-items: flex-start !important; - gap: 8px !important; - } - - .ehb-sum-table-wrap { - overflow-x: auto !important; - -webkit-overflow-scrolling: touch !important; - border-radius: 8px !important; - box-shadow: inset -7px 0 8px -7px rgba(15, 23, 42, 0.4) !important; - } - - .ehb-sum-table { - min-width: 780px !important; - } - - .ehb-mini-tabs { - overflow-x: auto !important; - max-width: 100% !important; - padding-bottom: 2px; - } - - /* 提示文案 H5 适配:单行显示不跨行 */ - .ehb-show-h5 { - display: inline !important; - } - - .ehb-hide-h5 { - display: none !important; - } - - .ehb-daily-table-title, - .ehb-daily-chart-title { - display: flex !important; - align-items: center !important; - flex-wrap: nowrap !important; - white-space: nowrap !important; - overflow: hidden !important; - max-width: 100% !important; - } - - .ehb-daily-table-head { - align-items: flex-start !important; - flex-direction: column !important; - gap: 8px !important; - } - - .ehb-export-btn, - .ehb-daily-export-btn { - display: inline-flex !important; - min-height: 36px !important; - margin-top: 0 !important; - align-self: flex-start; - } - - .ehb-title-sub { - font-size: 11px !important; - color: var(--bi-tertiary) !important; - margin-left: 4px !important; - white-space: nowrap !important; - overflow: hidden !important; - text-overflow: ellipsis !important; - } - - /* 4. H5 按住/悬浮图标 Tooltip 沉浸居中/屏内安全弹出,100% 绝对不超出屏外 */ - .ehb-mbar-col:hover .ehb-mbar-tooltip, - .ehb-rev-bar.is-income:hover .ehb-rev-income-tooltip, - .ehb-rev-bar.is-cost:hover .ehb-rev-cost-tooltip, - .ehb-top-bar-bg:hover .ehb-top-bar-tooltip, - .ehb-top-station-item:hover .ehb-top-bar-tooltip { - position: fixed !important; - top: 50% !important; - left: 50% !important; - right: auto !important; - bottom: auto !important; - transform: translate(-50%, -50%) !important; - width: calc(100vw - 32px) !important; - max-width: 320px !important; - z-index: 10002 !important; - box-shadow: 0 16px 40px rgba(15, 23, 42, 0.5) !important; - pointer-events: none; - animation: ehbTooltipCenterFade 0.2s ease-out !important; - } - - @keyframes ehbTooltipCenterFade { - from { - opacity: 0; - transform: translate(-50%, -46%); - } - to { - opacity: 1; - transform: translate(-50%, -50%); - } - } -} - -/* ===== 站日报 + 现结进账(体系A)· 增量,不覆盖既有 .ehb-table ===== */ -.ehb-seg--wrap { - display: flex; - flex-wrap: wrap; - grid-template-columns: none; - min-width: 0; - gap: 3px; -} -.ehb-seg--wrap button { - flex: 0 0 auto; - min-width: 64px; -} -.ehb-cash-banner { - display: flex; - flex-direction: column; - gap: 4px; - padding: 10px 14px; - border-radius: 10px; - background: #f0f9ff; - border: 1px solid #bae6fd; - color: #0c4a6e; - font-size: 12px; - line-height: 1.5; - margin-bottom: 12px; -} -.ehb-cash-banner strong { - font-size: 13px; - color: #0369a1; -} -.ehb-field-label { - display: flex; - flex-direction: column; - gap: 4px; - font-size: 12px; - color: #64748b; - font-weight: 600; -} -.ehb-native-select, -.ehb-native-input { - min-height: 36px; - height: 36px; - border: 1px solid #e2e8f0; - border-radius: 8px; - padding: 0 10px; - font-size: 13px; - color: #0f172a; - background: #fff; - min-width: 160px; -} -.ehb-native-input.is-num { - text-align: right; - font-variant-numeric: tabular-nums; -} -.ehb-btn--primary { - background: #0284c7; - color: #fff; - border-color: #0284c7; -} -.ehb-btn--primary:hover { - background: #0369a1; - color: #fff; - border-color: #0369a1; -} -.ehb-sd-card { - background: #fff; - border: 1px solid #e2e8f0; - border-radius: 12px; - overflow: hidden; -} -.ehb-table-card { - background: #fff; - border: 1px solid #e2e8f0; - border-radius: 12px; - overflow: hidden; -} -.ehb-sd-card__head, -.ehb-table-card__head { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 8px; - padding: 12px 14px; - border-bottom: 1px solid #f1f5f9; -} -.ehb-sd-card__title, -.ehb-table-card__title { - font-size: 14px; - font-weight: 700; - color: #0f172a; -} -.ehb-sd-card__hint, -.ehb-table-card__hint { - font-size: 11px; - color: #94a3b8; -} -.ehb-sd-scroll, -.ehb-table-scroll { - overflow: auto; -} -.ehb-empty-cell { - text-align: center !important; - color: #94a3b8; - padding: 28px 12px !important; -} -.ehb-row-actions { - display: flex; - gap: 10px; - flex-wrap: wrap; -} -.ehb-link-btn { - display: inline-flex; - align-items: center; - gap: 4px; - background: none; - border: none; - color: #0284c7; - font-size: 12px; - font-weight: 600; - cursor: pointer; - padding: 0; -} -.ehb-link-btn.is-danger { - color: #dc2626; -} -.ehb-muted-hint { - font-size: 12px; - color: #94a3b8; - align-self: flex-end; - padding-bottom: 6px; -} -.ehb-kpi-grid--4 { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 12px; -} -.ehb-kpi-card { - background: #fff; - border: 1px solid #e2e8f0; - border-radius: 12px; - padding: 14px 16px; -} -.ehb-kpi-card__label { - font-size: 12px; - color: #64748b; - font-weight: 600; - margin-bottom: 6px; -} -.ehb-kpi-card__value { - font-size: 22px; - font-weight: 800; - color: #0f172a; - font-variant-numeric: tabular-nums; - line-height: 1.2; -} -.ehb-kpi-card__sub { - margin-top: 6px; - font-size: 12px; - color: #94a3b8; -} - -/* 累计 KPI 的三项承担构成必须完整可读,禁止金额省略。 */ -@media (min-width: 768px) { - .ehb-host-kpi .ehb-kpi-dual__deck.is-triple { - display: grid !important; - grid-template-columns: repeat(3, minmax(0, 1fr)) !important; - align-items: start !important; - gap: 0 !important; - padding: 8px 10px !important; - } - - .ehb-host-kpi .ehb-kpi-dual__deck.is-triple .ehb-kpi-dual__detail { - display: flex !important; - min-width: 0 !important; - flex-direction: column !important; - align-items: flex-start !important; - gap: 3px !important; - padding: 0 8px !important; - overflow: visible !important; - } - - .ehb-host-kpi .ehb-kpi-dual__deck.is-triple .ehb-kpi-dual__detail:first-child { padding-left: 0 !important; } - .ehb-host-kpi .ehb-kpi-dual__deck.is-triple .ehb-kpi-dual__detail:last-child { padding-right: 0 !important; } - .ehb-host-kpi .ehb-kpi-dual__deck.is-triple .ehb-kpi-dual__detail + .ehb-kpi-dual__detail { border-left: 1px solid #e4eaf2; } - - .ehb-host-kpi .ehb-kpi-dual__detail-label, - .ehb-host-kpi .ehb-kpi-dual__detail-value { - display: block !important; - max-width: none !important; - overflow: visible !important; - text-overflow: clip !important; - white-space: nowrap !important; - } - - .ehb-host-kpi .ehb-kpi-dual__detail-label { color: #71819a; font: 600 10px/1.2 var(--bi-font); } - .ehb-host-kpi .ehb-kpi-dual__detail-value { - color: #26364f; - font: 700 10px/1.25 var(--bi-font-mono); - font-variant-numeric: tabular-nums; - letter-spacing: -0.04em; - } -} -.ehb-kpi-unit { - font-size: 13px; - font-weight: 600; - margin-left: 4px; - color: #64748b; -} -.ehb-station-trend { - display: flex; - gap: 8px; - overflow-x: auto; - padding: 8px 4px 4px; - min-height: 160px; - align-items: flex-end; -} -.ehb-station-trend__col { - flex: 0 0 48px; - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; -} -.ehb-station-trend__val { - font-size: 11px; - color: #64748b; - font-variant-numeric: tabular-nums; -} -.ehb-station-trend__bar-wrap { - width: 100%; - height: 110px; - display: flex; - align-items: flex-end; - justify-content: center; -} -.ehb-station-trend__bar { - width: 22px; - border-radius: 4px 4px 2px 2px; - background: linear-gradient(180deg, #60a5fa, #2563eb); -} -.ehb-station-trend__date { - font-size: 11px; - color: #94a3b8; -} -.ehb-dual-tables { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; -} -.ehb-tag--own { - background: #e0f2fe; - color: #0369a1; -} -.ehb-tag--ext { - background: #fff7ed; - color: #c2410c; -} -.ehb-cash-modal { - max-width: 720px; - width: calc(100% - 24px); -} -.ehb-cash-modal-note { - font-size: 12px; - color: #0369a1; - background: #f0f9ff; - border-radius: 8px; - padding: 8px 10px; - margin: 0 0 12px; -} -.ehb-form-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; - margin-bottom: 14px; -} -.ehb-form-grid label { - display: flex; - flex-direction: column; - gap: 6px; - font-size: 12px; - font-weight: 600; - color: #64748b; -} -.ehb-form-span2 { - grid-column: 1 / -1; -} -.ehb-cash-lines-head { - display: flex; - align-items: center; - justify-content: space-between; - margin: 8px 0; - font-size: 13px; - font-weight: 700; - color: #0f172a; -} -.ehb-manual-total { - display: flex; - flex-direction: column; - gap: 6px; - font-size: 12px; - font-weight: 600; - color: #64748b; -} -.ehb-cash-modal-foot { - display: flex; - justify-content: flex-end; - gap: 8px; - padding: 12px 16px; - border-top: 1px solid #f1f5f9; - background: #fff; -} -.ehb-cash-modal .ehb-modal-body { - overflow: auto; - max-height: min(70vh, 560px); - padding: 16px; -} -.ehb-toast { - position: fixed; - bottom: 24px; - left: 50%; - transform: translateX(-50%); - background: #0f172a; - color: #fff; - padding: 10px 16px; - border-radius: 999px; - font-size: 13px; - z-index: 10050; - box-shadow: 0 8px 24px rgba(15, 23, 42, 0.25); -} -.ehb-table .is-num, -.ehb-sum-table .is-num { - text-align: right; - font-variant-numeric: tabular-nums; -} -.ehb-table .is-mono { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 12px; -} -.ehb-table tr.is-total td { - font-weight: 700; - background: #f8fafc; -} -@media (max-width: 767px) { - .ehb-kpi-grid--4 { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - .ehb-dual-tables { - grid-template-columns: 1fr; - } - .ehb-form-grid { - grid-template-columns: 1fr; - } - .ehb-station-trend__col { - flex-basis: 46px; - } -} - -/* Runtime responsive layer: preserves the supplied prototype structure while - keeping live-data screens readable on tablet and touch devices. */ -.ehb-kpi-dual { - width: 100%; - text-align: left; - color: inherit; - font: inherit; -} - -.ehb-kpi-dual:focus-visible, -.ehb-pill-btn:focus-visible, -.ehb-fleet-btn:focus-visible, -.ehb-mini-tab:focus-visible, -.ehb-rail__item:focus-visible { - outline: 2px solid rgba(37, 99, 235, 0.55); - outline-offset: 2px; -} - -.ehb-mbar-col:focus-visible, -.ehb-rev-bar:focus-visible, -.ehb-top-station-item:focus-visible, -.ehb-region-legend-item:focus-visible, -.ehb-daily-bar-col:focus-visible { - outline: 2px solid rgba(37, 99, 235, 0.7); - outline-offset: 3px; -} - -@media (max-width: 1100px) { - .ehb-rail { - width: 64px; - } - .ehb-rail__item { - width: 52px; - } - .ehb-body { - padding: 14px 16px 28px; - } - .ehb-host-kpi { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - .ehb-insight { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - .ehb-insight__card:last-child { - grid-column: 1 / -1; - } - .ehb-daily-kpi-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - .ehb-daily-filter-row { - align-items: stretch; - } - .ehb-daily-filter-group:last-child { - justify-content: space-between; - } - .ehb-chart-box, - .ehb-sum-table-card { - padding: 14px; - } - .ehb-two-charts-row { - grid-template-columns: 1fr; - } - .ehb-modal-table-wrap { - overflow-x: auto; - -webkit-overflow-scrolling: touch; - box-shadow: inset -8px 0 10px -8px rgba(15, 23, 42, 0.32); - } - .ehb-modal-table { - min-width: 820px; - } - .ehb-h5-scroll-hint { - display: block; - margin: 0 0 6px; - padding: 4px 8px; - border-radius: 4px; - background: rgba(2, 132, 199, 0.08); - color: #0284c7; - font-size: 11px; - font-weight: 500; - text-align: center; - } -} - -@media (hover: none) and (pointer: coarse) { - .ehb-kpi-dual:active, - .ehb-pill-btn:active, - .ehb-fleet-btn:active, - .ehb-mini-tab:active, - .ehb-rail__item:active { - transform: scale(0.98); - } - .ehb-mbar-col:active .ehb-mbar-fill, - .ehb-rev-bar:active, - .ehb-top-station-item:active, - .ehb-region-legend-item:active, - .ehb-daily-bar-col:active .ehb-daily-bar-fill { - filter: brightness(0.96); - } -} - -@media (prefers-reduced-motion: reduce) { - .ehb-kpi-dual, - .ehb-mbar-fill, - .ehb-rev-bar, - .ehb-top-station-item, - .ehb-region-legend-item, - .ehb-daily-bar-fill { - transition: none !important; - animation: none !important; - } -} - -@media (max-width: 767px) { - /* 省份筛选:小屏只保留常用入口,剩余项由省份选择菜单承接。 */ - .ehb-province-tabs { - position: relative; - max-width: 100%; - } - .ehb-sum-table-card__head .ehb-mini-tabs.ehb-province-tabs__main { - display: inline-flex !important; - width: auto !important; - max-width: 100% !important; - overflow: visible !important; - } - .ehb-province-tabs__main > .ehb-mini-tab:not(.ehb-province-tabs__more):nth-child(n + 6) { - display: none !important; - } - .ehb-province-tabs__main > .ehb-province-tabs__more { - display: inline-flex !important; - align-items: center; - justify-content: center; - width: 36px; - min-width: 36px; - padding: 0 !important; - } - .ehb-province-tabs__menu { - position: absolute; - z-index: 30; - top: calc(100% + 6px); - left: 0; - right: auto; - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 4px; - width: min(340px, calc(100vw - 48px)); - max-height: 260px; - overflow-y: auto; - padding: 8px; - border: 1px solid #dbeafe; - border-radius: 8px; - background: #fff; - box-shadow: 0 12px 24px rgba(15, 23, 42, 0.14); - } - .ehb-province-tabs__menu button { - min-height: 34px; - padding: 5px 8px; - border: 0; - border-radius: 5px; - background: #f8fafc; - color: #475569; - font-size: 12px; - font-weight: 600; - text-align: left; - } - .ehb-province-tabs__menu button.is-active { - background: #e0f2fe; - color: #0284c7; - } - .ehb-daily-filter-group .ehb-modal-select[type="date"] { - width: calc(50% - 4px) !important; - min-width: 0 !important; - } - .ehb-daily-level-tag { - padding: 2px 4px; - font-size: 9px; - } - .ehb-daily-disclosure { - width: 15px; - height: 15px; - margin-right: 4px; - } - .ehb-daily-date-row td:first-child, - .ehb-tree-cell-l1 { - white-space: nowrap; - } - - /* 触屏优先:顶部筛选与分段页签保持清晰、可点的最小高度。 */ - .ehb-seg button, - .ehb-year-select-btn, - .ehb-btn, - .ehb-pill-btn, - .ehb-fleet-btn { - min-height: 40px !important; - } - - .ehb-seg button, - .ehb-year-select-btn, - .ehb-btn { - height: 40px !important; - } - - .ehb-year-dropdown__item { - min-height: 40px; - } - - .ehb-mini-tab { - min-height: 36px; - } - - /* Top5 站在小屏改为固定三列:排名、站名、统计值。每行同一基线,不再随名称长度错位。 */ - .ehb-top-stations-list { - gap: 4px; - } - - .ehb-top-station-item { - display: grid !important; - grid-template-columns: 20px minmax(0, 1fr) max-content; - column-gap: 8px; - width: 100%; - min-height: 36px; - padding: 4px 0; - text-align: left; - } - - .ehb-top-station-name { - width: auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .ehb-top-bar-bg { - display: none; - } - - .ehb-top-station-val { - width: auto; - min-width: 0; - color: #64748b; - font-size: 10px; - line-height: 1.25; - white-space: nowrap; - } - - .ehb-top-station-val__desktop { - display: none; - } - - .ehb-top-station-val__mobile { - display: inline; - } - - .ehb-region-legend-item { - min-height: 32px; - padding: 4px 0; - } - - /* 柱状图仍维持原尺寸,点击热区向外扩展而不挤压图形。 */ - .ehb-mbar-col, - .ehb-rev-bar { - position: relative; - } - - .ehb-mbar-col::after, - .ehb-rev-bar::after { - content: ""; - position: absolute; - inset: -8px -6px -6px; - } -} - -/* Runtime: keep the daily-table affordance visible on tablet and phone layouts. */ -@media (max-width: 1100px) { - .ehb-daily-table-scroll-hint { - display: block !important; - margin: 0 0 6px !important; - padding: 4px 8px; - border-radius: 4px; - background: rgba(2, 132, 199, 0.08); - color: #0284c7; - font-size: 11px; - font-weight: 500; - text-align: center; - } -} diff --git a/src/modules/energy/hydrogen-bi-v2/prototype-source/types.ts b/src/modules/energy/hydrogen-bi-v2/prototype-source/types.ts deleted file mode 100644 index 626925a..0000000 --- a/src/modules/energy/hydrogen-bi-v2/prototype-source/types.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** 能源 BI · 氢能总览嵌入功能 · 类型(宿主 bi-next #hydrogen/overview,非 OneOS V2) */ - -/** 我司成本三维度(本尊 2026-08-07) */ -export type CostDim = 'lease' | 'logistics' | 'ops' | 'pending'; - -/** 租赁成本二级 */ -export type LeaseKind = 'company_borne' | 'package_h2'; - -/** 运维成本二级 */ -export type OpsKind = 'abnormal' | 'transfer'; - -export type VerifyStatus = 'verified' | 'unverified'; -export type BorneBy = 'company' | 'customer'; -export type FleetScope = 'own' | 'external' | 'all'; -/** 按日 | 总览(站日报 / 现结登记已拆独立模块) */ -export type HostView = 'daily' | 'overview'; - -export interface H2OrderRow { - id: string; - occurredAt: string; - stationId: string; - stationName: string; - plateNo: string; - customerId: string; - customerName: string; - deptId: string; - deptName: string; - amount: number; - quantityKg: number; - unitPrice: number; - borneBy: BorneBy; - costDim: CostDim; - leaseKind?: LeaseKind; - opsKind?: OpsKind; - verifyStatus: VerifyStatus; - source: 'api' | 'manual' | 'fence'; - fleet: 'own' | 'external'; -} - -export interface StationPrepaid { - stationId: string; - stationName: string; - openingBalance: number | null; - openingAnchorLabel: string | null; - recharge: number; - consume: number; -} - -export const COST_DIM_LABEL: Record = { - lease: '租赁成本', - logistics: '物流成本', - ops: '运维成本', - pending: '待归属', -}; - -export const LEASE_KIND_LABEL: Record = { - company_borne: '我司承担', - package_h2: '包氢项目', -}; - -export const OPS_KIND_LABEL: Record = { - abnormal: '异动', - transfer: '调拨', -}; diff --git a/src/modules/energy/hydrogen-bi-v2/prototype-station-daily.css b/src/modules/energy/hydrogen-bi-v2/prototype-station-daily.css deleted file mode 100644 index d33be1b..0000000 --- a/src/modules/energy/hydrogen-bi-v2/prototype-station-daily.css +++ /dev/null @@ -1,905 +0,0 @@ -/* Exact station-overview primitives carried from the supplied prototype. */ -.ehb-shell--station-daily, -.sd-embedded { - --sd-ink: #0f172a; - --sd-muted: #64748b; - --sd-tertiary: #94a3b8; - --sd-line: rgba(15, 23, 42, 0.08); - --sd-cyan: #2563eb; - --sd-cyan-soft: #eff6ff; - --sd-surface: #fff; - --sd-font: - -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", - "Microsoft YaHei", "Noto Sans SC", sans-serif; - --sd-mono: - "JetBrains Mono", "Cascadia Mono", Consolas, "SF Mono", ui-monospace, - monospace; - color: #1e293b; - font-family: var(--sd-font); -} -.sd-embedded { - margin-top: 4px; - width: 100%; - min-width: 0; -} -.sd-topbar { - display: flex; - align-items: flex-end; - justify-content: space-between; - gap: 16px; - flex-wrap: wrap; - margin-bottom: 18px; -} -.sd-topbar--embedded { - margin-bottom: 14px; -} -.sd-topbar__updated { - margin: 4px 0 0; - font-size: 12px; - font-weight: 500; - color: #94a3b8; - font-variant-numeric: tabular-nums; - font-family: var(--sd-mono); -} -.sd-topbar__tools { - display: flex; - align-items: flex-end; - gap: 10px; - flex-wrap: wrap; -} -.sd-date { - position: relative; - display: inline-block; -} -.sd-date--right .sd-date__popover { - left: auto; - right: 0; -} -.sd-date__trigger { - display: inline-flex; - align-items: center; - gap: 8px; - min-height: 30px; - height: 30px; - padding: 0 10px 0 12px; - border: 1px solid #cbd5e1; - border-radius: 8px; - background: #fff; - color: var(--sd-ink); - cursor: pointer; -} -.sd-date__trigger:hover, -.sd-date__trigger.is-open { - border-color: #2563eb; - background: #fff; - box-shadow: 0 0 0 2px #2563eb1f; -} - -.sd-date__trigger:focus-visible, -.sd-btn:focus-visible, -.sd-hero-kpi--click:focus-visible, -.sd-share-seg:focus-visible, -.sd-share-legend__btn:focus-visible, -.sd-station-row:focus-visible, -.sd-board-mode > button[role="tab"]:focus-visible, -.sd-board-station-select:focus-visible { - outline: 2px solid rgba(37, 99, 235, 0.72); - outline-offset: 2px; -} -.sd-date__label { - font-size: 12px; - font-weight: 500; - color: var(--sd-muted); -} -.sd-date__value { - font-size: 12px; - font-weight: 600; - font-family: var(--sd-mono); - font-variant-numeric: tabular-nums; - color: #0f172a; -} -.sd-date__icon { - color: #94a3b8; - flex-shrink: 0; -} -.sd-date__popover { - position: absolute; - top: calc(100% + 8px); - left: 0; - z-index: 40; - width: 300px; - padding: 12px; - border-radius: 14px; - border: 1px solid rgba(148, 163, 184, 0.35); - background: #fff; - box-shadow: - 0 18px 40px -18px #0f172a47, - 0 8px 16px -10px #0284c72e; -} -.sd-date__header { display:flex; align-items:center; justify-content:space-between; margin-bottom:10px; } -.sd-date__title { font-size:14px; font-weight:800; color:var(--sd-ink); letter-spacing:-.02em; } -.sd-date__nav { display:inline-flex; align-items:center; justify-content:center; width:30px; height:30px; border:none; border-radius:8px; background:#f1f5f9; color:#475569; cursor:pointer; } -.sd-date__week { display:grid; grid-template-columns:repeat(7,1fr); margin-bottom:6px; text-align:center; font-size:11px; font-weight:700; color:#94a3b8; } -.sd-date__grid { display:grid; grid-template-columns:repeat(7,1fr); gap:3px; } -.sd-date__day { display:inline-flex; align-items:center; justify-content:center; height:32px; border:none; border-radius:8px; background:transparent; color:#334155; font-size:13px; font-weight:600; font-variant-numeric:tabular-nums; cursor:pointer; } -.sd-date__day.is-empty { cursor:default; pointer-events:none; }.sd-date__day.is-selected { background:var(--sd-cyan); color:#fff; font-weight:800; box-shadow:0 6px 14px -6px #0284c78c; }.sd-date__day.is-in-range:not(.is-selected) { background:#dbeafe; color:#1e40af; border-radius:0; } -.sd-date__range-tabs { display:grid; grid-template-columns:1fr 1fr; gap:6px; margin-bottom:10px; }.sd-date__range-tabs button { min-height:32px; border:1px solid #e2e8f0; border-radius:8px; background:#fff; color:#64748b; font-size:11px; font-weight:600; font-family:var(--sd-mono); cursor:pointer; padding:4px 6px; }.sd-date__range-tabs button.is-on { border-color:#2563eb; color:#1d4ed8; background:#eff6ff; box-shadow:0 0 0 2px #2563eb1a; } -.sd-date__today { border:none; background:transparent; color:var(--sd-cyan); font-size:12px; font-weight:700; cursor:pointer; padding:4px 6px; border-radius:6px; }.sd-date__apply { margin-left:auto; } -.sd-date__shortcuts { - display: flex; - gap: 6px; - margin-bottom: 10px; -} -.sd-date__shortcuts button { - flex: 1; - height: 28px; - border: 1px solid #e2e8f0; - border-radius: 7px; - background: #f8fafc; - color: #334155; - font-size: 12px; - font-weight: 600; - cursor: pointer; -} -.sd-date__shortcuts button:hover { - border-color: #93c5fd; - color: #1d4ed8; - background: #eff6ff; -} -.sd-date__range-fields { - display: grid; - gap: 8px; -} -.sd-date__range-fields label { - display: grid; - grid-template-columns: 62px 1fr; - align-items: center; - gap: 8px; - color: #64748b; - font-size: 12px; - font-weight: 600; -} -.sd-date__range-fields input { - height: 30px; - border: 1px solid #cbd5e1; - border-radius: 7px; - padding: 0 7px; - color: #0f172a; - background: #fff; - font: 600 12px var(--sd-mono); -} -.sd-date__footer { - display: flex; - justify-content: flex-end; - margin-top: 10px; - padding-top: 8px; - border-top: 1px solid #eef2f7; -} -.sd-date__footer--range { - justify-content: space-between; - align-items: center; - color: #64748b; - font: 500 11px var(--sd-mono); -} - -/* Station detail: kept in the same DOM/class hierarchy as the supplied prototype. */ -.sd-detail-top { display:flex; align-items:flex-start; justify-content:space-between; gap:14px; flex-wrap:wrap; margin-bottom:16px; } -.sd-detail-top__lead { display:flex; align-items:flex-start; gap:12px; } -.sd-detail-top__title { margin:0; font-size:16px; font-weight:700; color:var(--sd-ink); letter-spacing:-.01em; } -.sd-detail-top__meta { margin:4px 0 0; font-size:12px; color:var(--sd-muted); font-family:var(--sd-mono); font-variant-numeric:tabular-nums; } -.sd-detail-top__updated { margin:4px 0 0; font-size:12px; font-weight:500; color:#94a3b8; font-variant-numeric:tabular-nums; font-family:var(--sd-mono); } -.sd-detail-top__tools { display:flex; align-items:flex-end; gap:8px; flex-wrap:wrap; } -.sd-hero-kpis--detail { margin-bottom:16px; } -.sd-unit { margin-left:2px; font-size:11px; font-weight:500; color:var(--sd-muted); font-family:var(--sd-font); } -.sd-panel { background:#fff; border:1px solid var(--sd-line); border-radius:10px; padding:12px 14px; box-shadow:none; min-width:0; } -.sd-panel--block { width:100%; margin-top:16px; } -.sd-panel__title { margin:0; font-size:14px; font-weight:700; color:var(--sd-ink); letter-spacing:-.01em; } -.sd-panel__head-row { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:8px; } -.sd-table-scroll { overflow-x:auto; overflow-y:visible; margin-top:8px; max-width:100%; -webkit-overflow-scrolling:touch; } -.sd-table-scroll--matrix { border:1px solid #e2e8f0; border-radius:8px; background:#fff; } -.sd-bi-table { width:100%; border-collapse:separate; border-spacing:0; text-align:left; font-size:13px; min-width:0; } -.sd-bi-table th { font-size:12px; font-weight:500; color:var(--sd-muted); padding:8px 12px; border-bottom:1px solid var(--sd-line); white-space:nowrap; background:#f8fafc; text-align:left; } -.sd-bi-table td { font-size:13px; font-weight:400; color:#334155; padding:8px 12px; border-bottom:1px solid var(--sd-line); white-space:nowrap; vertical-align:middle; } -.sd-bi-table tr:last-child td { border-bottom:none; } -.sd-bi-table td.is-num,.sd-bi-table th.is-num { text-align:right; font-variant-numeric:tabular-nums; font-family:var(--sd-mono); font-size:12px; } -.sd-bi-table tr:hover td { background:#f8fafc; } -.sd-bi-table tr.is-total td { font-weight:700; background:#f8fafc; color:#0f172a; } -.sd-table-more { padding:8px !important; text-align:center; background:#f8fafc; } -.sd-table-more button { border:1px dashed #bfdbfe; border-radius:5px; padding:4px 12px; background:#fff; color:#0284c7; font-size:12px; font-weight:700; cursor:pointer; } -.sd-table-more button:hover { background:#eff6ff; } -.sd-bi-table .is-mono { font-family:var(--sd-mono); font-size:12px; color:#475569; } -.sd-bi-table--fill { width:100%; min-width:100%; } -.sd-bi-table--matrix { width:max(100%,1080px); min-width:100%; table-layout:auto; } -.sd-bi-table--matrix th:first-child,.sd-bi-table--matrix td:first-child { position:sticky; left:0; z-index:2; background:#fff; min-width:168px; max-width:220px; white-space:normal; word-break:break-word; box-shadow:4px 0 8px -6px #0f172a2e; } -.sd-bi-table--matrix thead th:first-child { z-index:3; background:#f8fafc; } -.sd-bi-table--matrix tr.is-total td:first-child,.sd-bi-table--matrix tr:hover td:first-child { background:#f8fafc; } -.sd-bi-table--matrix th.is-num,.sd-bi-table--matrix td.is-num { min-width:72px; padding-left:6px; padding-right:8px; text-align:right; } -.sd-bi-table--matrix.is-amount th.is-num,.sd-bi-table--matrix.is-amount td.is-num { min-width:92px; font-size:12px; } -.is-stock-up { color:#ef4444 !important; font-weight:700; } -.is-stock-down { color:#10b981 !important; font-weight:700; } -.is-stock-flat { color:#64748b; } -.sd-delta { display:inline-block; margin-left:3px; font-size:10px; line-height:1; vertical-align:middle; } -.sd-trend { position:relative; margin-top:8px; } -.sd-trend-legend-chip { display:inline-flex; align-items:center; gap:6px; font-size:12px; font-weight:500; color:#64748b; max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } -.sd-trend-legend-dot { width:8px; height:8px; border-radius:2px; background:#2563eb; flex:0 0 auto; display:inline-block; } -.sd-trend--fill { display:flex; align-items:flex-end; gap:6px; width:100%; min-height:220px; padding:12px 4px 4px; box-sizing:border-box; } -.sd-trend__col { flex:1 1 0; min-width:0; display:flex; flex-direction:column; align-items:center; gap:6px; } -.sd-trend__val { font-size:11px; color:#64748b; font-variant-numeric:tabular-nums; font-family:var(--sd-mono); } -.sd-trend__bar-wrap { width:100%; height:140px; display:flex; align-items:flex-end; justify-content:center; } -.sd-trend__bar { width:min(42px,70%); border-radius:4px 4px 2px 2px; background:linear-gradient(180deg,#60a5fa,#2563eb); } -.sd-trend__date { font-size:10px; color:#94a3b8; font-family:var(--sd-mono); font-variant-numeric:tabular-nums; white-space:nowrap; letter-spacing:-.02em; } -.sd-trend__col.is-hover .sd-trend__bar { filter:brightness(1.08); outline:2px solid rgba(37,99,235,.35); } -.sd-trend-tip { position:absolute; top:8px; right:12px; z-index:5; min-width:220px; max-width:320px; padding:10px 12px; border-radius:8px; border:1px solid #e2e8f0; background:#fffffff5; box-shadow:0 8px 20px #0f172a1f; pointer-events:none; } -.sd-trend-tip__date { font-size:12px; font-weight:700; color:#0f172a; font-family:var(--sd-mono); margin-bottom:6px; } -.sd-trend-tip__row { display:flex; align-items:center; gap:6px; font-size:12px; color:#334155; } -.sd-trend-tip__name { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } -.sd-trend-tip__row strong { font-family:var(--sd-mono); font-variant-numeric:tabular-nums; color:#0f172a; white-space:nowrap; } -.sd-trend-tip__sub { margin-top:4px; font-size:11px; color:#94a3b8; } -.sd-msel { position:relative; flex:0 0 auto; } -.sd-msel__trigger { display:inline-flex; align-items:center; gap:8px; height:30px; max-width:280px; padding:0 10px; border:1px solid #cbd5e1; border-radius:8px; background:#fff; cursor:pointer; color:#0f172a; } -.sd-msel.is-open .sd-msel__trigger,.sd-msel__trigger:hover { border-color:#2563eb; box-shadow:0 0 0 2px #2563eb1f; } -.sd-msel__label { font-size:12px; font-weight:500; color:#64748b; flex:0 0 auto; } -.sd-msel__value { font-size:12px; font-weight:600; color:#0f172a; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:160px; } -.sd-msel__chev { color:#94a3b8; flex:0 0 auto; } -.sd-msel__panel { position:absolute; top:calc(100% + 6px); right:0; z-index:40; width:min(320px,80vw); max-height:320px; display:flex; flex-direction:column; background:#fff; border:1px solid #e2e8f0; border-radius:10px; box-shadow:0 12px 28px #0f172a1f; overflow:hidden; } -.sd-msel__search { padding:10px 10px 6px; } -.sd-msel__search input { width:100%; height:32px; border:1px solid #e2e8f0; border-radius:8px; padding:0 10px; font-size:12px; box-sizing:border-box; } -.sd-msel__actions { display:flex; gap:8px; padding:0 10px 8px; } -.sd-msel__actions button { border:none; background:#f1f5f9; color:#334155; font-size:12px; font-weight:600; height:26px; padding:0 10px; border-radius:999px; cursor:pointer; } -.sd-msel__list { list-style:none; margin:0; padding:0 0 8px; overflow:auto; flex:1; } -.sd-msel__opt { width:100%; display:flex; align-items:flex-start; gap:8px; padding:8px 12px; border:none; background:transparent; cursor:pointer; text-align:left; } -.sd-msel__opt:hover { background:#f8fafc; }.sd-msel__opt.is-on { background:#eff6ff; } -.sd-msel__check { width:16px; height:16px; border-radius:4px; border:1px solid #cbd5e1; display:inline-flex; align-items:center; justify-content:center; flex:0 0 auto; margin-top:1px; color:#fff; background:#fff; } -.sd-msel__opt.is-on .sd-msel__check { background:#2563eb; border-color:#2563eb; } -.sd-msel__name { font-size:12px; color:#334155; line-height:1.35; }.sd-msel__empty { padding:16px; text-align:center; color:#94a3b8; font-size:12px; } -.sd-dual { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:12px; margin-top:16px; } -.sd-dual--cash { grid-template-columns:minmax(0,1fr) minmax(0,1fr); } -.sd-panel--grow { min-width:0; } -.sd-table-scroll--cash-lines { max-height:420px; overflow:auto; } -.sd-bi-table--ledger { width:100%; min-width:520px; } -.sd-bi-table--ledger td:first-child,.sd-bi-table--ledger th:first-child { white-space:normal; word-break:break-word; max-width:160px; } -.sd-more-btn { display:inline-flex; align-items:center; justify-content:center; gap:4px; width:100%; margin-top:10px; height:32px; border:1px dashed #cbd5e1; border-radius:8px; background:#f8fafc; color:#475569; font-size:12px; font-weight:600; cursor:pointer; } -.sd-more-btn:hover { border-color:#93c5fd; color:#2563eb; background:#eff6ff; } -.sd-pending-row td { height:110px; color:#94a3b8; text-align:center; font-size:12px; background:#fff; } -@media (max-width:767px) { .sd-detail-top__tools{width:100%;}.sd-detail-top__tools .sd-date{flex:1;}.sd-detail-top__tools .sd-date__trigger{width:100%;justify-content:space-between;}.sd-panel__head-row{flex-direction:column;align-items:stretch;}.sd-msel__trigger{max-width:none;width:100%;}.sd-msel__panel{left:0;right:0;width:auto;}.sd-trend__date{font-size:9px;} } -@media (max-width:1024px) { .sd-dual--cash { grid-template-columns:minmax(0,1fr); } } -.sd-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - height: 30px; - min-height: 30px; - padding: 0 11px; - border-radius: 8px; - font-size: 12px; - font-weight: 500; - cursor: pointer; - border: 1px solid var(--sd-line); - background: #fff; - color: #1e293b; -} -.sd-btn--ghost { - background: #fff; - border-color: var(--sd-line); - color: var(--sd-muted); -} -.sd-btn--ghost:hover { - border-color: #2563eb59; - color: #2563eb; -} -.sd-hero-kpis { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 12px; - margin-bottom: 12px; -} -.sd-hero-kpi { - position: relative; - background: #fff; - border: 1px solid var(--sd-line); - border-radius: 10px; - padding: 10px 12px; - box-shadow: none; - overflow: hidden; - display: flex; - flex-direction: column; - box-sizing: border-box; -} -.sd-hero-kpi--click { - width: 100%; - text-align: left; - cursor: pointer; -} -.sd-hero-kpi--click:hover, -.sd-station-row:hover { - border-color: #93c5fd; - box-shadow: 0 0 0 2px #2563eb14; -} -.sd-hero-kpi__label { - font-size: 12px; - font-weight: 500; - color: var(--sd-muted); - margin-bottom: 4px; -} -.sd-hero-kpi__value { - font-size: 20px; - font-weight: 700; - color: #0f172a; - font-family: var(--sd-mono); - font-variant-numeric: tabular-nums; - line-height: 1.2; - letter-spacing: -0.02em; - margin-bottom: 6px; - display: flex; - align-items: baseline; - gap: 2px; -} -.sd-hero-kpi__sub { - margin-top: auto; - font-size: 11px; - color: var(--sd-muted); - font-family: var(--sd-mono); - font-variant-numeric: tabular-nums; - background: #f8fafc; - border-radius: 6px; - padding: 4px 8px; -} -.sd-share-panel { - background: #fff; - border: 1px solid var(--sd-line); - border-radius: 10px; - padding: 12px 14px; - margin-bottom: 12px; -} -.sd-share-panel__head { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 12px; - margin-bottom: 12px; -} -.sd-share-panel__title, -.sd-section-title { - margin: 0; - font-size: 14px; - font-weight: 700; - color: #1e293b; -} -.sd-share-panel__title { - display: flex; - align-items: baseline; - flex-wrap: wrap; - gap: 8px; -} -.sd-share-panel__range { - font-size: 12px; - font-weight: 500; - color: var(--sd-muted); - font-family: var(--sd-mono); - font-variant-numeric: tabular-nums; -} -.sd-share-panel__total { - font-size: 11px; - color: var(--sd-muted); - font-family: var(--sd-mono); -} -.sd-share-panel__total strong { - color: var(--sd-ink); - font-variant-numeric: tabular-nums; - font-weight: 700; -} -.sd-share-track { - display: flex; - gap: 2px; - min-height: 44px; - border-radius: 8px; - overflow: hidden; -} -.sd-share-seg { - display: flex; - flex-direction: row; - justify-content: flex-start; - align-items: center; - gap: 8px; - min-width: 48px; - padding: 8px 12px; - border: none; - cursor: pointer; - color: #fff; - text-align: left; -} -.sd-share-seg--0 { - background: #2563eb; -} -.sd-share-seg--1 { - background: #0ea5e9; -} -.sd-share-seg--2 { - background: #6366f1; -} -.sd-share-seg--3 { - background: #8b5cf6; -} -.sd-share-seg--4 { - background: #f59e0b; -} -.sd-share-seg__pct { - font-size: 13px; - font-weight: 700; - font-variant-numeric: tabular-nums; - font-family: var(--sd-mono); - line-height: 1; - flex: 0 0 auto; -} -.sd-share-seg__name { - font-size: 12px; - font-weight: 500; - opacity: 0.92; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.sd-share-legend { - list-style: none; - margin: 12px 0 0; - padding: 0; - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - column-gap: 0; - row-gap: 6px; -} -.sd-share-legend > li:nth-child(odd) { - padding-right: 18px; - border-right: 1px solid var(--sd-line); -} -.sd-share-legend > li:nth-child(even) { - padding-left: 18px; -} -.sd-share-legend__btn { - width: 100%; - display: flex; - align-items: center; - gap: 8px; - padding: 6px 4px; - border: none; - background: transparent; - cursor: pointer; - text-align: left; - border-radius: 8px; -} -.sd-share-legend__swatch { - width: 10px; - height: 10px; - border-radius: 3px; - flex: 0 0 auto; -} -.sd-share-legend__rank { - min-width: 12px; - color: var(--sd-muted); - font-family: var(--sd-mono); - font-size: 11px; - font-weight: 700; - font-variant-numeric: tabular-nums; -} -.sd-share-legend__name { - flex: 0 1 auto; - min-width: 0; - max-width: min(22vw, 240px); - font-size: 12px; - font-weight: 600; - color: #334155; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.sd-share-legend__val { - flex: 0 0 auto; - font-size: 12px; - font-weight: 700; - color: var(--sd-muted); - font-variant-numeric: tabular-nums; - white-space: nowrap; -} -.sd-section-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 12px; - flex-wrap: wrap; -} -.sd-board-mode { - display: inline-flex; - align-items: center; - gap: 6px; - flex-wrap: wrap; -} -.sd-board-mode > button[role="tab"] { - height: 28px; - min-height: 28px; - padding: 0 12px; - border: 1px solid #e2e8f0; - border-radius: 7px; - background: #fff; - color: #64748b; - font-size: 12px; - font-weight: 600; - cursor: pointer; -} -.sd-board-mode > button[role="tab"].is-on { - border-color: #2563eb; - color: #1d4ed8; - background: #eff6ff; -} -.sd-board-station-select { - height: 28px; - min-height: 28px; - border: 1px solid #cbd5e1; - border-radius: 7px; - padding: 0 8px; - font-size: 12px; - color: #0f172a; - background: #fff; - max-width: 180px; -} -.sd-station-single-card { - display: flex; - flex-direction: column; - gap: 10px; -} -.sd-station-groups { - display: flex; - flex-direction: column; - gap: 18px; -} -.sd-station-group { - min-width: 0; -} -.sd-station-group__head { - display: flex; - align-items: baseline; - gap: 8px; - margin: 0 0 8px; -} -.sd-station-group__head h3 { - margin: 0; - color: #334155; - font-size: 13px; - font-weight: 700; -} -.sd-station-group__head span { - color: #94a3b8; - font-size: 11px; - font-weight: 600; - font-variant-numeric: tabular-nums; -} -.sd-station-group__empty { - display: flex; - align-items: center; - min-height: 52px; - margin: 0; - padding: 0 14px; - border: 1px dashed #cbd5e1; - border-radius: 10px; - background: #f8fafc; - color: #94a3b8; - font-size: 12px; - font-weight: 500; -} -.sd-station-row { - width: 100%; - border: 1px solid var(--sd-line); - border-radius: 10px; - background: #fff; - padding: 12px 14px; - cursor: pointer; - text-align: left; -} -.sd-station-row__main { - display: grid; - grid-template-columns: - auto minmax(120px, 1.2fr) minmax(100px, 0.8fr) minmax(280px, 2fr) - auto; - gap: 12px 16px; - align-items: center; -} -.sd-station-card__icon { - flex: 0 0 auto; - width: 34px; - height: 34px; - border-radius: 8px; - display: inline-flex; - align-items: center; - justify-content: center; - background: #eff6ff; - color: #2563eb; -} -.sd-station-card__name { - font-size: 14px; - font-weight: 700; - color: var(--sd-ink); - line-height: 1.35; -} -.sd-station-card__region { - display: inline-flex; - align-items: center; - gap: 4px; - margin-top: 2px; - font-size: 12px; - font-weight: 500; - color: var(--sd-muted); -} -.sd-spark { - display: flex; - align-items: flex-end; - gap: 3px; - height: 48px; - padding: 4px 2px 0; -} -.sd-spark--empty { - align-items: center; - justify-content: center; - border-radius: 6px; - background: #f8fafc; - border: 1px dashed #e2e8f0; - color: #94a3b8; - font-size: 11px; - font-weight: 500; -} -.sd-spark--empty:after { - content: "本区间无加氢量"; -} -.sd-spark__col { - flex: 1; - height: 100%; - display: flex; - align-items: flex-end; -} -.sd-spark__bar { - width: 100%; - border-radius: 3px 3px 1px 1px; - background: linear-gradient(180deg, #60a5fa, #2563eb); - min-height: 3px; -} -.sd-station-row__metrics { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 8px; -} -.sd-station-row__metrics > div { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; -} -.sd-station-card__m-label { - font-size: 11px; - font-weight: 500; - color: var(--sd-muted); - margin-bottom: 2px; -} -.sd-station-row__metrics strong { - font-size: 13px; - font-weight: 700; - font-family: var(--sd-mono); - font-variant-numeric: tabular-nums; - color: #0f172a; -} -.sd-station-row__metrics strong span { - margin-left: 2px; - font-size: 11px; - font-weight: 500; - color: var(--sd-muted); - font-family: var(--sd-font); -} -.sd-station-card__share { - font-size: 12px; - font-weight: 700; - color: #2563eb; - font-variant-numeric: tabular-nums; - font-family: var(--sd-mono); -} -.sd-station-card__go { - width: 32px; - height: 32px; - border-radius: 999px; - display: inline-flex; - align-items: center; - justify-content: center; - color: var(--sd-cyan); - background: var(--sd-cyan-soft); -} -@media (max-width: 900px) { - .sd-hero-kpis { - grid-template-columns: 1fr 1fr; - } - .sd-station-row__main { - display: flex; - flex-wrap: wrap; - } - .sd-station-row__main > .sd-spark { - flex: 1 1 140px; - } - .sd-station-row__metrics { - flex: 1 1 100%; - } -} -@media (max-width: 560px) { - .sd-hero-kpis { - grid-template-columns: 1fr; - } - .sd-hero-kpi__value { - font-size: 18px; - } - .sd-station-row__metrics { - grid-template-columns: 1fr 1fr; - } - .sd-share-legend { - grid-template-columns: 1fr; - } -} - -/* 触屏窄屏:保留 2×2 指标密度,并将时间与站点操作变成明确的纵向分组。 */ -@media (max-width: 560px) { - .sd-topbar, - .sd-topbar--embedded { - align-items: stretch; - gap: 10px; - } - - .sd-topbar__updated { - margin: 0; - font-size: 11px; - } - - .sd-topbar__tools { - width: 100%; - align-items: center; - gap: 8px; - } - - .sd-date { - min-width: 0; - flex: 1 1 220px; - } - - .sd-date__trigger { - width: 100%; - min-width: 0; - min-height: 40px; - height: 40px; - justify-content: flex-start; - padding: 0 8px; - } - - .sd-date__label { - display: none; - } - - .sd-date__value { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 11px; - } - - .sd-date__popover, - .sd-date--right .sd-date__popover { - position: fixed; - right: 12px; - /* Shell 的移动端底部导航为固定层,日期弹层必须避开它。 */ - bottom: calc(72px + env(safe-area-inset-bottom)); - left: 12px; - top: auto; - width: auto; - max-width: none; - max-height: calc(100dvh - 84px); - overflow: auto; - } - - .sd-date__shortcuts button, - .sd-date__range-fields input, - .sd-btn { - min-height: 40px; - height: 40px; - } - - .sd-hero-kpis { - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; - } - - .sd-hero-kpi { - min-height: 88px; - padding: 10px; - } - - .sd-hero-kpi__label, - .sd-hero-kpi__sub { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .sd-hero-kpi__value { - font-size: 18px; - } - - .sd-share-panel { - padding: 12px; - } - - .sd-share-panel__head { - align-items: flex-start; - flex-direction: column; - gap: 4px; - } - - .sd-share-panel__total { - align-self: flex-end; - } - - .sd-section-head { - align-items: flex-start; - flex-direction: column; - gap: 8px; - } - - .sd-board-mode { - width: 100%; - } - - .sd-board-mode > button[role="tab"] { - flex: 0 0 auto; - min-height: 40px; - } - - .sd-board-station-select { - min-height: 40px; - max-width: 100%; - flex: 1 1 140px; - } - - .sd-station-row { - min-height: 56px; - padding: 12px; - } - - .sd-station-card__name { - font-size: 13px; - } -} - -@media (hover: none) and (pointer: coarse) { - .sd-date__trigger:active, - .sd-btn:active, - .sd-hero-kpi--click:active, - .sd-share-seg:active, - .sd-share-legend__btn:active, - .sd-station-row:active, - .sd-board-mode > button[role="tab"]:active { - transform: scale(0.985); - filter: brightness(0.98); - } -} - -@media (prefers-reduced-motion: reduce) { - .sd-date__trigger, - .sd-btn, - .sd-hero-kpi--click, - .sd-share-seg, - .sd-share-legend__btn, - .sd-station-row, - .sd-board-mode > button[role="tab"] { - transition: none !important; - animation: none !important; - } -} - -@media (max-width: 359px) { - .sd-hero-kpis { - grid-template-columns: 1fr; - } -} diff --git a/src/modules/energy/hydrogen-daily/components/DailyDetailTable.tsx b/src/modules/energy/hydrogen-daily/components/DailyDetailTable.tsx deleted file mode 100644 index 1d0bc25..0000000 --- a/src/modules/energy/hydrogen-daily/components/DailyDetailTable.tsx +++ /dev/null @@ -1,375 +0,0 @@ -import { useMemo, useState } from 'react'; -import { ChevronRight, Download, RefreshCw } from 'lucide-react'; -import { AnimatePresence, motion } from 'motion/react'; -import * as XLSX from 'xlsx'; -import TrendBadge from '../../TrendBadge'; -import type { HydrogenDailyDetailCustomer, HydrogenDailyDetailStation, HydrogenDailyRow } from '../../types'; -import type { DailyDetailState } from '../../HydrogenDaily'; -import { SortableColumnHeader, sortBy, toggleSort, type SortDirection } from '../../components/SortableColumnHeader'; - -type DailySortKey = 'date' | 'price' | 'kg' | 'fee' | 'chainPct'; - -interface DailyDetailTableProps { - rows: HydrogenDailyRow[]; - totalKg: number; - totalFee: number; - expanded: Set; - highlightedDate?: string | null; - details: Record; - onToggle: (date: string) => void; - onRetryDetail: (date: string) => void; -} - -export function DailyDetailTable({ - rows, - totalKg, - totalFee, - expanded, - highlightedDate, - details, - onToggle, - onRetryDetail, -}: DailyDetailTableProps) { - const [sortKey, setSortKey] = useState('date'); - const [sortDirection, setSortDirection] = useState('desc'); - const sortedRows = useMemo(() => sortBy(rows, sortKey, sortDirection, (row, key) => { - if (key === 'price') return row.totalKg > 0 ? row.totalFee / row.totalKg : 0; - return row[key === 'kg' ? 'totalKg' : key === 'fee' ? 'totalFee' : key]; - }), [rows, sortDirection, sortKey]); - const changeSort = (nextKey: DailySortKey) => { - const next = toggleSort(sortKey, sortDirection, nextKey); - setSortKey(next.key); - setSortDirection(next.direction); - }; - return ( -
-
-

- 每日加氢数据明细 - (日期 → 加氢站 → 客户 → 车辆/来源) -

-
- {rows.length} 天 - -
-
- -
-
-
- - - - - 站点余额 -
-
- 合计 - - {formatNumber(totalKg, 2)} - ¥{formatNumber(totalFee, 0)} - 以源表为准 -
- {sortedRows.map(row => { - const open = expanded.has(row.date); - const highlighted = highlightedDate === row.date; - const abnormal = Math.abs(row.chainPct) >= 0.3; - const background = highlighted - ? 'bg-sky-50 ring-1 ring-inset ring-sky-200' - : abnormal - ? row.chainPct > 0 ? 'bg-emerald-50/35' : 'bg-red-50/35' - : ''; - return ( -
- - - {open ? ( - onRetryDetail(row.date)} - sortKey={sortKey} - sortDirection={sortDirection} - /> - ) : null} - -
- ); - })} -
-
-
- ); -} - -interface StationRowsProps { - date: string; - fallbackStations: HydrogenDailyRow['stations']; - detail?: DailyDetailState; - onRetry: () => void; - sortKey: DailySortKey; - sortDirection: SortDirection; -} - -function StationRows({ date, fallbackStations, detail, onRetry, sortKey, sortDirection }: StationRowsProps) { - const [openStations, setOpenStations] = useState>(new Set()); - const [openCustomers, setOpenCustomers] = useState>(new Set()); - const stations = detail?.data?.stations; - const sortedStations = useMemo(() => sortBy(stations ?? [], sortKey, sortDirection, (station, key) => dailyDetailValue(station, key)), [sortDirection, sortKey, stations]); - - const toggleStation = (station: HydrogenDailyDetailStation) => { - const key = `${date}:${station.id}`; - setOpenStations(previous => toggleSet(previous, key)); - }; - - const toggleCustomer = (station: HydrogenDailyDetailStation, customer: HydrogenDailyDetailCustomer) => { - const key = `${date}:${station.id}:${customer.id}:${customer.name}`; - setOpenCustomers(previous => toggleSet(previous, key)); - }; - - return ( - - {detail?.loading ? : null} - {detail?.error ? ( -
- 明细读取失败,请重试。 - -
- ) : null} - {!detail ? : null} - {stations?.length === 0 ? ( -
当日无站点明细
- ) : sortedStations.map((station, index) => { - const stationKey = `${date}:${station.id}`; - const stationOpen = openStations.has(stationKey); - return ( -
- - - {stationOpen ? ( - toggleCustomer(station, customer)} - sortKey={sortKey} - sortDirection={sortDirection} - /> - ) : null} - -
- ); - })} -
- ); -} - -function CustomerRows({ - date, - station, - openCustomers, - onToggle, - sortKey, - sortDirection, -}: { - date: string; - station: HydrogenDailyDetailStation; - openCustomers: Set; - onToggle: (customer: HydrogenDailyDetailCustomer) => void; - sortKey: DailySortKey; - sortDirection: SortDirection; -}) { - const sortedCustomers = useMemo(() => sortBy(station.customers, sortKey, sortDirection, (customer, key) => dailyDetailValue(customer, key)), [sortDirection, sortKey, station.customers]); - return ( - - {sortedCustomers.map(customer => { - const customerKey = `${date}:${station.id}:${customer.id}:${customer.name}`; - const customerOpen = openCustomers.has(customerKey); - return ( -
- - - {customerOpen ? : null} - -
- ); - })} -
- ); -} - -function VehicleRows({ customer, sortKey, sortDirection }: { customer: HydrogenDailyDetailCustomer; sortKey: DailySortKey; sortDirection: SortDirection }) { - const sortedVehicles = useMemo(() => sortBy(customer.vehicles, sortKey, sortDirection, (vehicle, key) => dailyDetailValue(vehicle, key)), [customer.vehicles, sortDirection, sortKey]); - return ( - - {sortedVehicles.map(vehicle => ( -
-
- {vehicle.time} - {vehicle.plateNo} - - {vehicle.vehicleScope === 'lingniu' ? '羚牛' : '外部'} - -
-
- {vehicle.source} - {formatVerifyStatus(vehicle.verifyStatus)} -
- {formatNumber(vehicle.kg, 3)} - ¥{formatNumber(vehicle.fee, 2)} - -
- ))} -
- ); -} - -function dailyDetailValue( - row: HydrogenDailyDetailStation | HydrogenDailyDetailCustomer | HydrogenDailyDetailCustomer['vehicles'][number], - key: DailySortKey, -) { - if (key === 'date') return 'time' in row ? `${row.time} ${row.plateNo}` : 'name' in row ? row.name : ''; - if (key === 'price') return row.kg > 0 ? row.fee / row.kg : 0; - if (key === 'chainPct') return row.kg; - return row[key]; -} - -function DetailStatus({ label }: { label: string }) { - return
{label}
; -} - -function toggleSet(previous: Set, key: string) { - const next = new Set(previous); - next.has(key) ? next.delete(key) : next.add(key); - return next; -} - -function formatVerifyStatus(value: string) { - const normalized = value.toUpperCase(); - if (normalized === 'VERIFIED' || normalized === 'PASS') return '已核验'; - if (normalized === 'FAILED' || normalized === 'REJECT') return '异常'; - return '待核验'; -} - -function formatStationType(value: string) { - const normalized = value.toLowerCase(); - if (normalized === 'self' || normalized === 'internal') return '自营站'; - if (normalized === 'external' || normalized === 'partner') return '合作站'; - return '加氢站'; -} - -function exportDailyRows(rows: HydrogenDailyRow[], details: Record) { - const workbook = XLSX.utils.book_new(); - const summaryRows = rows.flatMap(row => row.stations.length > 0 - ? row.stations.map(station => ({ - 日期: row.date, - 加氢站: station.name, - 单价元每Kg: station.pricePerKg, - 加氢量Kg: station.kg, - 成本元: station.fee, - 日环比: row.chainPct, - })) - : [{ 日期: row.date, 加氢站: '', 单价元每Kg: 0, 加氢量Kg: row.totalKg, 成本元: row.totalFee, 日环比: row.chainPct }]); - XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(summaryRows), '日报汇总'); - - const recordRows = Object.values(details).flatMap(state => { - if (!state.data) return []; - return state.data.stations.flatMap(station => station.customers.flatMap(customer => ( - customer.vehicles.map(vehicle => ({ - 日期: state.data?.date, - 时间: vehicle.time, - 加氢站: station.name, - 客户: customer.name, - 车牌: vehicle.plateNo, - 车辆归属: vehicle.vehicleScope === 'lingniu' ? '羚牛' : '外部', - 来源: vehicle.source, - 核验状态: formatVerifyStatus(vehicle.verifyStatus), - 单价元每Kg: vehicle.unitPrice, - 加氢量Kg: vehicle.kg, - 成本元: vehicle.fee, - })) - ))); - }); - if (recordRows.length > 0) XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(recordRows), '已下钻流水'); - const start = rows.at(-1)?.date ?? '开始'; - const end = rows[0]?.date ?? '结束'; - XLSX.writeFile(workbook, `氢能按日_${start}_${end}.xlsx`); -} - -function formatNumber(value: number, digits: number): string { - return value.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits }); -} diff --git a/src/modules/energy/hydrogen-daily/components/DailyKpiGrid.tsx b/src/modules/energy/hydrogen-daily/components/DailyKpiGrid.tsx deleted file mode 100644 index 1d5369d..0000000 --- a/src/modules/energy/hydrogen-daily/components/DailyKpiGrid.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { Fuel, TrendingUp, Truck, Zap } from 'lucide-react'; -import type { HydrogenDailyVehicleScope } from '../model'; - -interface DailyKpiGridProps { - rangeLabel: string; - rangeText: string; - totalKg: number; - activeDays: number; - dayCount: number; - averageKg: number; - stationCount: number; - vehicleScope: HydrogenDailyVehicleScope; - lingniuKg: number; - externalKg: number; - selectedStationName?: string; -} - -const VEHICLE_LABEL: Record = { - all: '全部车辆', - lingniu: '羚牛车辆', - external: '外部车辆', -}; - -export function DailyKpiGrid({ - rangeLabel, - rangeText, - totalKg, - activeDays, - dayCount, - averageKg, - stationCount, - vehicleScope, - lingniuKg, - externalKg, - selectedStationName, -}: DailyKpiGridProps) { - return ( -
- - - - -
- ); -} - -function formatKg(value: number): string { - return `${value.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`; -} - -function KpiCard({ - icon: Icon, - title, - value, - unit, - helper, - tone, - smallValue = false, -}: { - icon: typeof Fuel; - title: string; - value: string | number; - unit?: string; - helper: string; - tone: 'blue' | 'green' | 'amber' | 'purple'; - smallValue?: boolean; -}) { - const toneClass = { - blue: 'bg-blue-50 text-blue-600', - green: 'bg-emerald-50 text-emerald-600', - amber: 'bg-amber-50 text-amber-600', - purple: 'bg-violet-50 text-violet-600', - }[tone]; - - return ( -
-
- {title} - - - -
-
- - {value} - - {unit ? {unit} : null} -
-

{helper}

-
- ); -} diff --git a/src/modules/energy/hydrogen-daily/components/DailyTrendChart.tsx b/src/modules/energy/hydrogen-daily/components/DailyTrendChart.tsx deleted file mode 100644 index a376e03..0000000 --- a/src/modules/energy/hydrogen-daily/components/DailyTrendChart.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { - Bar, - BarChart, - CartesianGrid, - Cell, - LabelList, - ReferenceLine, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from 'recharts'; -import type { HydrogenDailyRow } from '../../types'; -import type { HydrogenDailyTrendPoint } from '../model'; - -interface DailyTrendChartProps { - rows: HydrogenDailyTrendPoint[]; - averageKg: number; - peakDay: HydrogenDailyRow | null; - lowDay: HydrogenDailyRow | null; - zeroDays: number; - selectedDate?: string | null; - onSelectDate?: (date: string) => void; -} - -export function DailyTrendChart({ - rows, - averageKg, - peakDay, - lowDay, - zeroDays, - selectedDate, - onSelectDate, -}: DailyTrendChartProps) { - const selectActiveDate = (state: { activeLabel?: string | number } | null) => { - if (state?.activeLabel) onSelectDate?.(String(state.activeLabel)); - }; - - return ( -
-
-
-

- 每日加氢量 - (点击柱体定位到对应日期明细) -

-
-
-
- - -
- 时间单位:日 · 单位 Kg -
-
- -
- - - 0 ? 'text-amber-600' : 'text-emerald-600'} /> -
- -
-
- - - - value.slice(5)} - tick={{ fontSize: 10, fill: '#94a3b8' }} - tickLine={false} - axisLine={{ stroke: '#e2e8f0' }} - interval={0} - /> - - [ - `${Number(value ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg`, - name === 'lingniuKg' ? '羚牛车辆' : '外部车辆', - ]} - labelFormatter={date => `日期 ${date}`} - contentStyle={{ borderRadius: 8, borderColor: '#e2e8f0', fontSize: 12, boxShadow: '0 10px 30px rgba(15,23,42,.12)' }} - cursor={{ fill: 'rgba(2,132,199,.05)' }} - /> - {averageKg > 0 ? ( - - ) : null} - - - - - - - - - - - - {rows.map(row => )} - - - formatCompact(Number(value ?? 0))} - style={{ fill: '#64748b', fontSize: 9, fontWeight: 600 }} - /> - {rows.map(row => )} - - - -
-
-
- ); -} - -function Legend({ color, label }: { color: string; label: string }) { - return ( - - - {label} - - ); -} - -function TrendFact({ label, value, valueClass = 'text-slate-800' }: { label: string; value: string; valueClass?: string }) { - return ( - - {label} - {value} - - ); -} - -function formatAxis(value: number): string { - if (value >= 10_000) return `${(value / 10_000).toFixed(value % 10_000 === 0 ? 0 : 1)}万`; - if (value >= 1_000) return `${Math.round(value / 1_000)}k`; - return `${Math.round(value)}`; -} - -function formatCompact(value: number): string { - return value.toLocaleString('zh-CN', { maximumFractionDigits: 0 }); -} diff --git a/src/modules/energy/hydrogen-daily/components/StationDailyOverview.tsx b/src/modules/energy/hydrogen-daily/components/StationDailyOverview.tsx deleted file mode 100644 index eb28391..0000000 --- a/src/modules/energy/hydrogen-daily/components/StationDailyOverview.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Building2, Gauge, Wallet } from 'lucide-react'; -import type { HydrogenDailyStationOption } from '../model'; - -interface StationDailyOverviewProps { - station: HydrogenDailyStationOption; - totalKg: number; - totalFee: number; - averagePrice: number; -} - -export function StationDailyOverview({ station, totalKg, totalFee, averagePrice }: StationDailyOverviewProps) { - return ( -
-
- - - -
-
单站按日视图
-

{station.name}

-
-
-
- - - -
-
- ); -} - -function StationFact({ icon: Icon, label, value }: { icon: typeof Gauge; label: string; value: string }) { - return ( -
-
{label}
-
{value}
-
- ); -} diff --git a/src/modules/energy/hydrogen-daily/model.test.ts b/src/modules/energy/hydrogen-daily/model.test.ts deleted file mode 100644 index 51412b3..0000000 --- a/src/modules/energy/hydrogen-daily/model.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import type { HydrogenDailyRow } from '../types.js'; -import { - buildHydrogenDailyTrend, - getQuickRange, - filterHydrogenRowsByStation, - getHydrogenDailyStations, - getRangeModeLabel, - mergeHydrogenDailyRows, - normalizeRange, - summarizeHydrogenRows, -} from './model.js'; - -test('快捷日期继续使用本地自然日并覆盖本周、本月和近15天', () => { - const now = new Date(2026, 7, 12, 23, 30); - assert.deepEqual(getQuickRange('thisWeek', now), { - start: '2026-08-10', - end: '2026-08-12', - }); - assert.deepEqual(getQuickRange('thisMonth', now), { - start: '2026-08-01', - end: '2026-08-12', - }); - assert.deepEqual(getQuickRange('last15', now), { - start: '2026-07-29', - end: '2026-08-12', - }); -}); - -test('全部车辆由两类真实日报按日期和站点合并', () => { - const lingniu: HydrogenDailyRow[] = [{ - date: '2026-08-11', totalKg: 100, totalFee: 2_400, chainPct: 0, customerType: 'lingniu', - stations: [{ id: 1, name: 'A站', kg: 100, fee: 2_400, pricePerKg: 24, chainPct: 0 }], - }]; - const external: HydrogenDailyRow[] = [{ - date: '2026-08-11', totalKg: 50, totalFee: 1_000, chainPct: 0, customerType: 'external', - stations: [{ id: 1, name: 'A站', kg: 50, fee: 1_000, pricePerKg: 20, chainPct: 0 }], - }]; - - const merged = mergeHydrogenDailyRows(lingniu, external)!; - assert.equal(merged[0].totalKg, 150); - assert.equal(merged[0].totalFee, 3_400); - assert.equal(merged[0].stations[0].pricePerKg, 22.67); - assert.deepEqual(buildHydrogenDailyTrend(lingniu, external, 'all', null), [{ - date: '2026-08-11', totalKg: 150, lingniuKg: 100, externalKg: 50, - }]); -}); - -test('趋势数据同时遵守车辆归属和单站筛选', () => { - const lingniu: HydrogenDailyRow[] = [{ - date: '2026-08-11', totalKg: 130, totalFee: 3_000, chainPct: 0, customerType: 'lingniu', - stations: [ - { id: 1, name: 'A站', kg: 80, fee: 1_920, pricePerKg: 24, chainPct: 0 }, - { id: 2, name: 'B站', kg: 50, fee: 1_080, pricePerKg: 21.6, chainPct: 0 }, - ], - }]; - const external: HydrogenDailyRow[] = [{ - date: '2026-08-11', totalKg: 20, totalFee: 400, chainPct: 0, customerType: 'external', - stations: [{ id: 1, name: 'A站', kg: 20, fee: 400, pricePerKg: 20, chainPct: 0 }], - }]; - - assert.deepEqual(buildHydrogenDailyTrend(lingniu, external, 'lingniu', 1), [{ - date: '2026-08-11', totalKg: 80, lingniuKg: 80, externalKg: 0, - }]); -}); - -test('自定义日期倒置时仅交换查询边界', () => { - assert.deepEqual(normalizeRange('2026-08-12', '2026-08-01'), { - start: '2026-08-01', - end: '2026-08-12', - }); - assert.equal(getRangeModeLabel('custom'), '自定义区间'); - assert.equal(getRangeModeLabel('last15'), '近 15 天'); -}); - -test('每日加氢统计保持排序、有效天、站点去重和峰谷口径', () => { - const rows: HydrogenDailyRow[] = [ - { - date: '2026-08-12', - totalKg: 0, - totalFee: 0, - chainPct: -1, - customerType: 'lingniu', - stations: [{ id: 1, name: 'A站', kg: 0, fee: 0, pricePerKg: 0, chainPct: -1 }], - }, - { - date: '2026-08-10', - totalKg: 100, - totalFee: 2_000, - chainPct: 0, - customerType: 'lingniu', - stations: [{ id: 1, name: 'A站', kg: 100, fee: 2_000, pricePerKg: 20, chainPct: 0 }], - }, - { - date: '2026-08-11', - totalKg: 300, - totalFee: 7_500, - chainPct: 2, - customerType: 'lingniu', - stations: [{ id: 2, name: 'B站', kg: 300, fee: 7_500, pricePerKg: 25, chainPct: 2 }], - }, - ]; - - const summary = summarizeHydrogenRows(rows); - assert.deepEqual(rows.map(row => row.date), ['2026-08-12', '2026-08-10', '2026-08-11']); - assert.deepEqual(summary.trendData.map(row => row.date), ['2026-08-10', '2026-08-11', '2026-08-12']); - assert.equal(summary.totalKg, 400); - assert.equal(summary.totalFee, 9_500); - assert.equal(summary.activeDays, 2); - assert.equal(summary.avgKg, 200); - assert.equal(summary.avgPrice, 23.75); - assert.equal(summary.stationCount, 2); - assert.equal(summary.peakDay?.date, '2026-08-11'); - assert.equal(summary.lowDay?.date, '2026-08-10'); - assert.equal(summary.zeroDays, 1); -}); - -test('空数据保持零值且没有峰谷日', () => { - assert.deepEqual(summarizeHydrogenRows(null), { - trendData: [], - totalKg: 0, - totalFee: 0, - activeDays: 0, - stationCount: 0, - avgKg: 0, - avgPrice: 0, - peakDay: null, - lowDay: null, - zeroDays: 0, - }); -}); - -test('站点筛选按站点 ID 重算日报总量、费用和环比', () => { - const rows: HydrogenDailyRow[] = [ - { - date: '2026-08-11', totalKg: 150, totalFee: 3_400, chainPct: 0, customerType: 'lingniu', - stations: [ - { id: 1, name: 'A站', kg: 100, fee: 2_400, pricePerKg: 24, chainPct: 0 }, - { id: 2, name: 'B站', kg: 50, fee: 1_000, pricePerKg: 20, chainPct: 0 }, - ], - }, - { - date: '2026-08-10', totalKg: 80, totalFee: 1_760, chainPct: 0, customerType: 'lingniu', - stations: [{ id: 1, name: 'A站', kg: 80, fee: 1_760, pricePerKg: 22, chainPct: 0 }], - }, - ]; - - assert.deepEqual(getHydrogenDailyStations(rows), [ - { id: 1, name: 'A站', totalKg: 180, totalFee: 4_160 }, - { id: 2, name: 'B站', totalKg: 50, totalFee: 1_000 }, - ]); - - const filtered = filterHydrogenRowsByStation(rows, 1)!; - assert.deepEqual(filtered.map(row => ({ date: row.date, kg: row.totalKg, fee: row.totalFee, chain: row.chainPct })), [ - { date: '2026-08-11', kg: 100, fee: 2_400, chain: 0.25 }, - { date: '2026-08-10', kg: 80, fee: 1_760, chain: 0 }, - ]); - assert.equal(summarizeHydrogenRows(filtered).totalFee, 4_160); -}); diff --git a/src/modules/energy/hydrogen-daily/model.ts b/src/modules/energy/hydrogen-daily/model.ts deleted file mode 100644 index f64f03e..0000000 --- a/src/modules/energy/hydrogen-daily/model.ts +++ /dev/null @@ -1,208 +0,0 @@ -import type { HydrogenDailyRow } from '../types'; - -export { - formatYmd, - getQuickRange, - getRangeModeLabel, - normalizeRange, - QUICK_PICK_OPTIONS, - type RangeMode, -} from '../daily-range/model'; - -export function summarizeHydrogenRows(rows: HydrogenDailyRow[] | null) { - const source = rows ?? []; - // 图表固定按日期升序;复制数组避免改变接口返回及表格原始顺序。 - const trendData = [...source].sort((left, right) => left.date.localeCompare(right.date)); - const totalKg = source.reduce((total, row) => total + row.totalKg, 0); - const totalFee = source.reduce((total, row) => total + row.totalFee, 0); - const activeDays = source.filter(row => row.totalKg > 0).length; - const stationIds = new Set(); - source.forEach(row => row.stations.forEach(station => stationIds.add(station.id))); - const peakDay = trendData.reduce( - (best, item) => (!best || item.totalKg > best.totalKg ? item : best), - null, - ); - const lowDay = trendData - .filter(item => item.totalKg > 0) - .reduce( - (low, item) => (!low || item.totalKg < low.totalKg ? item : low), - null, - ); - - return { - trendData, - totalKg, - totalFee, - activeDays, - stationCount: stationIds.size, - avgKg: activeDays > 0 ? totalKg / activeDays : 0, - avgPrice: totalKg > 0 ? totalFee / totalKg : 0, - peakDay, - lowDay, - zeroDays: source.filter(row => row.totalKg === 0).length, - }; -} - -export interface HydrogenDailyStationOption { - id: number; - name: string; - totalKg: number; - totalFee: number; -} - -export type HydrogenDailyVehicleScope = 'all' | 'lingniu' | 'external'; -export type HydrogenDailyBoardScope = 'global' | 'station'; - -export interface HydrogenDailyTrendPoint { - date: string; - totalKg: number; - lingniuKg: number; - externalKg: number; -} - -function round2(value: number): number { - return Math.round(value * 100) / 100; -} - -/** - * “全部车辆”由羚牛与外部车辆两次真实查询合并得到。合并只累加账本 - * 已返回的加氢量和成本,不引入原型里的演示数据。 - */ -export function mergeHydrogenDailyRows( - lingniuRows: HydrogenDailyRow[] | null, - externalRows: HydrogenDailyRow[] | null, -): HydrogenDailyRow[] | null { - if (lingniuRows === null || externalRows === null) return null; - - const rowsByDate = new Map(); - for (const row of [...lingniuRows, ...externalRows]) { - const rows = rowsByDate.get(row.date) ?? []; - rows.push(row); - rowsByDate.set(row.date, rows); - } - - const merged = [...rowsByDate.entries()].map(([date, rows]) => { - const stationsById = new Map(); - for (const row of rows) { - for (const station of row.stations) { - const current = stationsById.get(station.id); - const kg = round2((current?.kg ?? 0) + station.kg); - const fee = round2((current?.fee ?? 0) + station.fee); - stationsById.set(station.id, { - id: station.id, - name: station.name || current?.name || `站点 #${station.id}`, - kg, - fee, - // 跨车辆归属聚合后展示实际成本加权均价。 - pricePerKg: kg > 0 ? round2(fee / kg) : 0, - chainPct: 0, - }); - } - } - const stations = [...stationsById.values()].sort((left, right) => right.kg - left.kg); - return { - date, - totalKg: round2(stations.reduce((sum, station) => sum + station.kg, 0)), - totalFee: round2(stations.reduce((sum, station) => sum + station.fee, 0)), - chainPct: 0, - customerType: 'lingniu' as const, - stations, - }; - }); - - return recomputeHydrogenDailyChains(merged); -} - -function recomputeHydrogenDailyChains(rows: HydrogenDailyRow[]): HydrogenDailyRow[] { - const ascending = [...rows] - .map(row => ({ ...row, stations: row.stations.map(station => ({ ...station })) })) - .sort((left, right) => left.date.localeCompare(right.date)); - - let previousTotalKg = 0; - const stationPreviousKg = new Map(); - for (const row of ascending) { - row.chainPct = previousTotalKg > 0 ? (row.totalKg - previousTotalKg) / previousTotalKg : 0; - previousTotalKg = row.totalKg; - for (const station of row.stations) { - const previousStationKg = stationPreviousKg.get(station.id) ?? 0; - station.chainPct = previousStationKg > 0 ? (station.kg - previousStationKg) / previousStationKg : 0; - stationPreviousKg.set(station.id, station.kg); - } - } - return ascending.sort((left, right) => right.date.localeCompare(left.date)); -} - -/** Build one stable station selector from the current date range. */ -export function getHydrogenDailyStations(rows: HydrogenDailyRow[] | null): HydrogenDailyStationOption[] { - const stations = new Map(); - for (const row of rows ?? []) { - for (const station of row.stations) { - const current = stations.get(station.id); - stations.set(station.id, { - id: station.id, - name: station.name, - totalKg: (current?.totalKg ?? 0) + station.kg, - totalFee: (current?.totalFee ?? 0) + station.fee, - }); - } - } - return [...stations.values()].sort((left, right) => right.totalKg - left.totalKg || left.name.localeCompare(right.name)); -} - -/** - * Daily endpoint returns all stations in the selected date range. This keeps - * station drill-down local and recomputes each day's totals and chain change. - */ -export function filterHydrogenRowsByStation( - rows: HydrogenDailyRow[] | null, - stationId: number | null, -): HydrogenDailyRow[] | null { - if (rows === null || stationId === null) return rows; - - const filtered = rows.map(row => { - const stations = row.stations.filter(station => station.id === stationId); - return { - ...row, - totalKg: stations.reduce((sum, station) => sum + station.kg, 0), - totalFee: stations.reduce((sum, station) => sum + station.fee, 0), - stations, - }; - }); - return recomputeHydrogenDailyChains(filtered); -} - -/** Build the prototype's blue/orange stacked daily series from real queries. */ -export function buildHydrogenDailyTrend( - lingniuRows: HydrogenDailyRow[] | null, - externalRows: HydrogenDailyRow[] | null, - vehicleScope: HydrogenDailyVehicleScope, - stationId: number | null, -): HydrogenDailyTrendPoint[] { - if (lingniuRows === null || externalRows === null) return []; - const lingniu = filterHydrogenRowsByStation(lingniuRows, stationId) ?? []; - const external = filterHydrogenRowsByStation(externalRows, stationId) ?? []; - const byDate = new Map(); - - for (const row of lingniu) { - byDate.set(row.date, { - date: row.date, - lingniuKg: vehicleScope === 'external' ? 0 : row.totalKg, - externalKg: 0, - totalKg: vehicleScope === 'external' ? 0 : row.totalKg, - }); - } - for (const row of external) { - const current = byDate.get(row.date) ?? { - date: row.date, - lingniuKg: 0, - externalKg: 0, - totalKg: 0, - }; - const externalKg = vehicleScope === 'lingniu' ? 0 : row.totalKg; - current.externalKg = externalKg; - current.totalKg = round2(current.lingniuKg + externalKg); - byDate.set(row.date, current); - } - - return [...byDate.values()].sort((left, right) => left.date.localeCompare(right.date)); -} diff --git a/src/modules/energy/hydrogen-overview/components/DistributionCharts.tsx b/src/modules/energy/hydrogen-overview/components/DistributionCharts.tsx deleted file mode 100644 index 2a31c59..0000000 --- a/src/modules/energy/hydrogen-overview/components/DistributionCharts.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import { - Bar, - BarChart, - Cell, - LabelList, - Pie, - PieChart, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from 'recharts'; -import { useMemo, useState } from 'react'; -import type { HydrogenRegionShare, HydrogenStationFull, HydrogenStationTop } from '../../types'; -import { - buildRegionDrillPayload, - buildStationDrillPayload, - type OverviewDrillPayload, - type OverviewDrillRequest, - type OverviewScope, -} from '../model'; -import { OverviewDrillDialog } from './OverviewDrillDialog'; - -const REGION_COLORS = [ - '#3b82f6', '#22d3ee', '#a855f7', '#f59e0b', - '#10b981', '#ef4444', '#6366f1', '#14b8a6', - '#94a3b8', -]; - -interface YAxisTickProps { - x?: number; - y?: number; - index?: number; - payload?: { value: string }; -} - -function RankYAxisTick({ x = 0, y = 0, index = 0, payload }: YAxisTickProps) { - return ( - - - - {index + 1} - - - {payload?.value} - - - ); -} - -interface DistributionChartsProps { - top5: HydrogenStationTop[]; - regions: HydrogenRegionShare[]; - stations: HydrogenStationFull[]; - yearKg: number; - onSelectStation?: (stationId: number) => void; - scope?: OverviewScope; - scopeLabel?: string | null; - onDrillRequest?: (request: OverviewDrillRequest) => void; -} - -export function DistributionCharts({ top5, regions, stations, yearKg, onSelectStation, scope = 'global', scopeLabel, onDrillRequest }: DistributionChartsProps) { - const [regionGranularity, setRegionGranularity] = useState<'province' | 'city'>('city'); - const [drill, setDrill] = useState(null); - const [selectedStationId, setSelectedStationId] = useState(null); - const provinceRegions = useMemo(() => { - const totals = new Map(); - for (const station of stations) { - const province = station.province?.trim() || '未归属'; - totals.set(province, (totals.get(province) ?? 0) + station.kg); - } - return [...totals.entries()] - .map(([region, kg]) => ({ region, kg, share: kg / Math.max(1, yearKg) })) - .sort((a, b) => b.kg - a.kg); - }, [stations, yearKg]); - const visibleRegions = regionGranularity === 'province' ? provinceRegions : regions; - const openStation = (stationId: number) => { - const station = stations.find(item => item.id === stationId); - if (!station) return; - setSelectedStationId(station.id); - if (onDrillRequest) onDrillRequest({ kind: 'station', key: String(station.id), label: station.name, entityId: station.id }); - else setDrill(buildStationDrillPayload(station)); - }; - const openRegion = (region: HydrogenRegionShare) => { - setSelectedStationId(null); - if (onDrillRequest) onDrillRequest({ kind: 'region', key: `${regionGranularity}:${region.region}`, label: region.region }); - else setDrill(buildRegionDrillPayload(region, stations, regionGranularity)); - }; - const scopeText = scope === 'station' && scopeLabel ? ` · ${scopeLabel}` : ''; - return ( - <> -
-
-
-
加氢站加氢量 Top5{scopeText}
-
单位:Kg
-
-
- - { - const stationId = (state as { activePayload?: { payload?: HydrogenStationTop }[] } | undefined)?.activePayload?.[0]?.payload?.id; - if (stationId) openStation(stationId); - }} - > - - } - /> - [`${Number(val ?? 0).toLocaleString('zh-CN')} Kg`, '加氢量']} - /> - - { - const value = Number(v ?? 0); - return value >= 1000 ? `${(value / 1000).toFixed(1)}k` : String(value); - }} fontSize={11} fontWeight={700} fill="#475569" /> - - - -
-
- -
-
-
各区域加氢占比{scopeText}
-
- - -
-
-
-
- - - openRegion(entry as unknown as HydrogenRegionShare)} - > - {visibleRegions.map((_, i) => ( - - ))} - - `${(Number(v ?? 0) / 1000).toFixed(2)}T`} contentStyle={{ borderRadius: 12, fontSize: 12 }} /> - - -
-
年合计
-
{(yearKg / 1000).toFixed(2)}T
-
-
-
- {visibleRegions.map((r, i) => ( - - ))} -
-
-
-
- { setDrill(null); setSelectedStationId(null); }} - onPrimaryAction={selectedStationId && onSelectStation ? () => { const stationId = selectedStationId; setDrill(null); setSelectedStationId(null); onSelectStation(stationId); } : undefined} - /> - - ); -} diff --git a/src/modules/energy/hydrogen-overview/components/HydrogenOverviewSkeleton.tsx b/src/modules/energy/hydrogen-overview/components/HydrogenOverviewSkeleton.tsx deleted file mode 100644 index 1195e14..0000000 --- a/src/modules/energy/hydrogen-overview/components/HydrogenOverviewSkeleton.tsx +++ /dev/null @@ -1,80 +0,0 @@ -export function HydrogenOverviewSkeleton() { - return ( -
-
-
-
- - {/* 5 卡占位 */} -
- {Array.from({ length: 5 }).map((_, i) => ( -
-
-
-
-
-
-
-
-
-
-
- ))} -
- - {/* 月度柱图占位 */} -
-
-
-
-
-
- {[60, 75, 50, 80, 35, 90, 45].map((h, i) => ( -
- ))} -
-
- -
-
-
-
-
-
-
- {[100, 78, 56, 40, 28].map((w, i) => ( -
-
-
-
-
-
- ))} -
-
-
-
-
-
-
-
-
- {Array.from({ length: 5 }).map((_, i) => ( -
-
-
-
-
- ))} -
-
-
-
- -
- - 正在加载氢能总览… -
-
- ); -} diff --git a/src/modules/energy/hydrogen-overview/components/InsightCards.tsx b/src/modules/energy/hydrogen-overview/components/InsightCards.tsx deleted file mode 100644 index 56faaf7..0000000 --- a/src/modules/energy/hydrogen-overview/components/InsightCards.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { Activity, ChevronDown, Shield, TrendingDown } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; -import type { HydrogenMonthlyPoint, HydrogenStationFull } from '../../types'; -import { - buildStationDrillPayload, - formatKg as fmtKg, - type OverviewDrillPayload, - type OverviewDrillRequest, -} from '../model'; -import { OverviewDrillDialog } from './OverviewDrillDialog'; - -interface InsightCardsProps { - monthAvgKg: number; - bestMonth: HydrogenMonthlyPoint | null; - latestMonth: HydrogenMonthlyPoint | undefined; - monthMomentum: number | null; - top5Share: number; - customerGrossMarginPct: number; - stationAvgKg: number; - stationCount: number; - yearProfitValue: string; - yearProfitUnit: string; - yearRevenueValue: string; - yearRevenueUnit: string; - stations: HydrogenStationFull[]; - onSelectStation: (stationId: number) => void; - onDrillRequest?: (request: OverviewDrillRequest) => void; -} - -export function InsightCards({ - monthAvgKg, - bestMonth, - latestMonth, - monthMomentum, - top5Share, - customerGrossMarginPct, - stationAvgKg, - stationCount, - yearProfitValue, - yearProfitUnit, - yearRevenueValue, - yearRevenueUnit, - stations, - onSelectStation, - onDrillRequest, -}: InsightCardsProps) { - const [rankingOpen, setRankingOpen] = useState(false); - const [drill, setDrill] = useState(null); - const [selectedStationId, setSelectedStationId] = useState(null); - const rankingRef = useRef(null); - const highestKg = stations[0]?.kg || 1; - useEffect(() => { - if (!rankingOpen) return; - const closeOnOutsideClick = (event: MouseEvent) => { - if (rankingRef.current && !rankingRef.current.contains(event.target as Node)) setRankingOpen(false); - }; - document.addEventListener('mousedown', closeOnOutsideClick); - return () => document.removeEventListener('mousedown', closeOnOutsideClick); - }, [rankingOpen]); - const openStation = (station: HydrogenStationFull) => { - setRankingOpen(false); - setSelectedStationId(station.id); - if (onDrillRequest) onDrillRequest({ kind: 'station', key: String(station.id), label: station.name, entityId: station.id }); - else setDrill(buildStationDrillPayload(station)); - }; - return ( - <> -
-
-
- -
-
-
月度加氢异常波动
-
- {monthMomentum === null ? '暂无对比' : `${monthMomentum >= 0 ? '+' : ''}${monthMomentum.toFixed(1)}%`} -
-
- {latestMonth ? `${latestMonth.month} 加氢 ${fmtKg(latestMonth.kg).value}${fmtKg(latestMonth.kg).unit}` : '暂无月度数据'} - {bestMonth ? ` · 峰值 ${bestMonth.month}` : ''} - {monthAvgKg > 0 ? ` · 月均 ${fmtKg(monthAvgKg).value}${fmtKg(monthAvgKg).unit}` : ''} -
-
-
- -
setRankingOpen((v) => !v)} - style={{ cursor: 'pointer' }} - title="点击查看加氢站加氢量排名" - > -
- -
-
-
头部加氢站占比
-
Top5 {top5Share.toFixed(1)}%
-
- 共 {stationCount} 站 · 单站年均 {fmtKg(stationAvgKg).value}{fmtKg(stationAvgKg).unit} · 点击展开加氢量排名 -
-
- - {rankingOpen && ( -
e.stopPropagation()} - role="listbox" - aria-label="加氢站加氢量排名" - > -
- 加氢站加氢量排名 - - 高 → 低 · 共 {stationCount} 站 - -
-
- {stations.map((station, index) => ( - - ))} - {stations.length === 0 && ( -
当前筛选下暂无站点数据
- )} -
-
- )} -
- -
-
= 0 ? 'is-ok' : 'is-neg'}`}> - -
-
-
客户单毛利率
-
= 0 ? 'is-pos' : 'is-neg'}`}> - {customerGrossMarginPct.toFixed(1)}% -
-
- 客户单毛利 {yearProfitValue}{yearProfitUnit} · 客户收入 {yearRevenueValue}{yearRevenueUnit} - {customerGrossMarginPct < 0 ? ' · 需关注客户价格与站点成本' : ' · 当前客户单保持正毛利'} -
-
-
-
- { setDrill(null); setSelectedStationId(null); }} - onPrimaryAction={selectedStationId ? () => { const stationId = selectedStationId; setDrill(null); setSelectedStationId(null); onSelectStation(stationId); } : undefined} - /> - - ); -} diff --git a/src/modules/energy/hydrogen-overview/components/KpiSection.tsx b/src/modules/energy/hydrogen-overview/components/KpiSection.tsx deleted file mode 100644 index ddcf916..0000000 --- a/src/modules/energy/hydrogen-overview/components/KpiSection.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { Activity, Fuel, Search, Truck, Wallet, Zap } from 'lucide-react'; -import { useState, type ReactNode } from 'react'; -import type { HydrogenKpi } from '../../types'; -import { - buildMetricDrillPayload, - formatKg as fmtKg, - formatYuan as fmtYuan, - type OverviewDrillPayload, - type OverviewDrillRequest, - type OverviewMetricKey, - type OverviewScope, -} from '../model'; -import { OverviewDrillDialog } from './OverviewDrillDialog'; - -interface KpiCardProps { - icon: ReactNode; - metricKey: OverviewMetricKey; - label: string; - hero: { value: string; unit: string }; - rows: { label: string; value: string }[]; - tone: 'blue' | 'green' | 'amber' | 'purple'; - valueClass?: string; - onOpen: (key: OverviewMetricKey) => void; -} - -const TONE_CLASS = { - blue: 'bg-blue-50 text-blue-600', - green: 'bg-emerald-50 text-emerald-600', - amber: 'bg-amber-50 text-amber-600', - purple: 'bg-violet-50 text-violet-600', -} as const; - -function KpiCard({ icon, metricKey, label, hero, rows, tone, onOpen }: KpiCardProps) { - const isYuan = hero.value.startsWith('¥'); - const numValue = isYuan ? hero.value.replace('¥', '') : hero.value; - return ( -
onOpen(metricKey)} - title="点击查看真实汇总及可用下钻明细" - > -
- - {label} - - 钻取 - - - {icon} -
-
- {isYuan && ¥} - {numValue} - {hero.unit && {hero.unit}} -
-
- {rows.map((row) => ( - {row.label} {row.value} - ))} -
-
- ); -} - -interface KpiSectionProps { - kpi: HydrogenKpi; - scope?: OverviewScope; - scopeLabel?: string | null; - onDrillRequest?: (request: OverviewDrillRequest) => void; -} - -export function KpiSection({ kpi: k, scope = 'global', scopeLabel, onDrillRequest }: KpiSectionProps) { - const [drill, setDrill] = useState(null); - const yearKgFmt = fmtKg(k.yearKg); - const yearFeeFmt = fmtYuan(k.yearFee); - const yearProfitFmt = fmtYuan(k.yearProfit); - const ourYearKgFmt = fmtKg(k.ourYearKg); - const customerYearKgFmt = fmtKg(k.customerYearKg); - const monthKgFmt = fmtKg(k.monthKg); - const monthFeeFmt = fmtYuan(k.monthFee); - const todayKgFmt = fmtKg(k.todayKg); - const todayFeeFmt = fmtYuan(k.todayFee); - const customerYearFee = Math.max(0, k.yearFee - k.ourYearFee); - const customerYearFeeFmt = fmtYuan(customerYearFee); - const yearRevenueFmt = fmtYuan(k.yearRevenue); - - const openDrill = (key: OverviewMetricKey) => { - if (onDrillRequest) onDrillRequest({ kind: 'metric', key, label: key }); - else setDrill(buildMetricDrillPayload(k, key)); - }; - - return ( - <> -
- } tone="blue" label="累计加氢量" hero={yearKgFmt} rows={[{ label: '我司', value: `${ourYearKgFmt.value}${ourYearKgFmt.unit}` }, { label: '客户', value: `${customerYearKgFmt.value}${customerYearKgFmt.unit}` }]} onOpen={openDrill} /> - } tone="blue" label="累计加氢费" hero={{ value: `¥${yearFeeFmt.value}`, unit: yearFeeFmt.unit }} rows={[{ label: '我司', value: `¥${fmtYuan(k.ourYearFee).value}${fmtYuan(k.ourYearFee).unit}` }, { label: '客户', value: `¥${customerYearFeeFmt.value}${customerYearFeeFmt.unit}` }]} onOpen={openDrill} /> - } tone="green" label="客户单毛利" hero={{ value: `¥${yearProfitFmt.value}`, unit: yearProfitFmt.unit }} rows={[{ label: '收入', value: `¥${yearRevenueFmt.value}${yearRevenueFmt.unit}` }, { label: '成本', value: `¥${customerYearFeeFmt.value}${customerYearFeeFmt.unit}` }]} onOpen={openDrill} /> - } tone="amber" label="本月加氢" hero={monthKgFmt} rows={[{ label: '加氢费', value: `¥${monthFeeFmt.value}${monthFeeFmt.unit}` }, { label: '占年比', value: `${k.yearKg > 0 ? (k.monthKg / k.yearKg * 100).toFixed(1) : '0.0'}%` }]} onOpen={openDrill} /> - } tone="purple" label="本日加氢" hero={todayKgFmt} rows={[{ label: '加氢费', value: `¥${todayFeeFmt.value}${todayFeeFmt.unit}` }, { label: '占月比', value: `${k.monthKg > 0 ? (k.todayKg / k.monthKg * 100).toFixed(1) : '0.0'}%` }]} onOpen={openDrill} /> -
- setDrill(null)} /> - - ); -} diff --git a/src/modules/energy/hydrogen-overview/components/MonthlyCharts.tsx b/src/modules/energy/hydrogen-overview/components/MonthlyCharts.tsx deleted file mode 100644 index b741934..0000000 --- a/src/modules/energy/hydrogen-overview/components/MonthlyCharts.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { - Bar, - BarChart, - LabelList, - Legend, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from 'recharts'; -import { useState } from 'react'; -import type { HydrogenMonthlyPoint } from '../../types'; -import { - buildMonthDrillPayload, - formatYuan as fmtYuan, - type OverviewDrillPayload, - type OverviewDrillRequest, - type OverviewScope, -} from '../model'; -import { OverviewDrillDialog } from './OverviewDrillDialog'; - -type MonthlyChartPoint = HydrogenMonthlyPoint & { monthLabel: string }; - -interface MonthlyChartsProps { - activeYear: number; - monthly: HydrogenMonthlyPoint[]; - monthlyDual: MonthlyChartPoint[]; - scope?: OverviewScope; - scopeLabel?: string | null; - onDrillRequest?: (request: OverviewDrillRequest) => void; -} - -export function MonthlyCharts({ activeYear, monthly, monthlyDual, scope = 'global', scopeLabel, onDrillRequest }: MonthlyChartsProps) { - const [drill, setDrill] = useState(null); - const openMonthPoint = (point: MonthlyChartPoint | undefined) => { - if (!point) return; - if (onDrillRequest) onDrillRequest({ kind: 'month', key: point.month, label: `${point.month} 月度经营明细` }); - else setDrill(buildMonthDrillPayload(point)); - }; - const openMonthFromChart = (state: unknown) => { - openMonthPoint((state as { activePayload?: { payload?: MonthlyChartPoint }[] } | undefined)?.activePayload?.[0]?.payload); - }; - const openMonthFromBar = (entry: unknown) => { - openMonthPoint((entry as { payload?: MonthlyChartPoint } | undefined)?.payload); - }; - const scopeText = scope === 'station' && scopeLabel ? ` · ${scopeLabel}` : ''; - return ( - <> - {monthly.length > 0 && ( -
-
-
{activeYear} 年月度加氢量{scopeText}
-
单位:Kg
-
-
- - - - - [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })} Kg`, '加氢量']} - labelFormatter={(d) => `${d}`} - contentStyle={{ borderRadius: 12, fontSize: 12 }} - cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }} - /> - - - - { - const total = Number(value ?? 0); - return total >= 1000 ? `${(total / 1000).toFixed(1)}k` : total.toFixed(0); - }} fill="#475569" fontSize={10} fontWeight={700} /> - - - -
-
- )} - - {monthly.length > 0 && ( -
-
-
{activeYear} 年月度收支对比{scopeText}
-
单位:元
-
-
- - - - - - { - const f = fmtYuan(Number(v ?? 0)); - return [`¥${f.value} ${f.unit}`, name]; - }} - contentStyle={{ borderRadius: 12, fontSize: 12 }} - cursor={{ fill: 'rgba(148, 163, 184, 0.06)' }} - /> - - - - -
-
- )} - setDrill(null)} /> - - ); -} diff --git a/src/modules/energy/hydrogen-overview/components/OverviewDrillDialog.tsx b/src/modules/energy/hydrogen-overview/components/OverviewDrillDialog.tsx deleted file mode 100644 index 1ee60ee..0000000 --- a/src/modules/energy/hydrogen-overview/components/OverviewDrillDialog.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { ChevronLeft, Database, ExternalLink, Search, X } from 'lucide-react'; -import { useEffect, useMemo, useState } from 'react'; -import { createPortal } from 'react-dom'; -import type { OverviewDrillPayload } from '../model'; -import { SortableColumnHeader, sortBy, toggleSort, type SortDirection } from '../../components/SortableColumnHeader'; - -interface OverviewDrillDialogProps { - payload: OverviewDrillPayload | null; - onClose: () => void; - onPrimaryAction?: () => void; - onGroupByChange?: (groupBy: 'station' | 'customer' | 'vehicle') => void; - onRowSelect?: (row: Record) => void; -} - -const TONE_CLASS = { - default: 'text-slate-900', - blue: 'text-sky-600', - green: 'text-emerald-600', - amber: 'text-amber-600', - red: 'text-rose-600', -} as const; - -export function OverviewDrillDialog({ payload, onClose, onPrimaryAction, onGroupByChange, onRowSelect }: OverviewDrillDialogProps) { - const [sortKey, setSortKey] = useState(''); - const [sortDirection, setSortDirection] = useState('desc'); - useEffect(() => { - if (!payload) return; - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = 'hidden'; - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') onClose(); - }; - document.addEventListener('keydown', onKeyDown); - return () => { - document.body.style.overflow = previousOverflow; - document.removeEventListener('keydown', onKeyDown); - }; - }, [onClose, payload]); - - useEffect(() => { - if (!payload?.columns.length) return; - setSortKey(payload.columns[0].key); - setSortDirection('desc'); - }, [payload]); - - const sortedRows = useMemo( - () => payload ? sortBy(payload.rows, sortKey, sortDirection, (row, key) => row[key]) : [], - [payload, sortDirection, sortKey], - ); - const changeSort = (nextKey: string) => { - const next = toggleSort(sortKey, sortDirection, nextKey); - setSortKey(next.key); - setSortDirection(next.direction); - }; - - if (!payload || typeof document === 'undefined') return null; - - return createPortal( -
{ - if (event.target === event.currentTarget) onClose(); - }} - role="presentation" - > -
-
-
- -
-

- - {payload.title} -

-

{payload.subtitle}

-
-
- -
- -
-
- {payload.metrics.map(metric => ( -
-
{metric.label}
-
{metric.value}
-
- ))} -
- - {payload.groupByOptions?.length && onGroupByChange ? ( -
-
汇总维度
-
- {payload.groupByOptions.map(groupBy => ( - - ))} -
-
- ) : null} - - {payload.columns.length > 0 && payload.rows.length > 0 ? ( -
- - - - {payload.columns.map(column => ( - - ))} - - - - {sortedRows.map((row, rowIndex) => ( - onRowSelect?.(row)} - title={onRowSelect && payload.rowActionLabel ? payload.rowActionLabel : undefined} - > - {payload.columns.map(column => ( - - ))} - - ))} - -
{row[column.key]}
-
- ) : ( -
- -
真实明细尚未接入当前总览接口
-

{payload.emptyMessage}

-
- )} - - {payload.primaryActionLabel && onPrimaryAction ? ( -
- -
- ) : null} -
-
-
, - document.body, - ); -} diff --git a/src/modules/energy/hydrogen-overview/components/OverviewDrillTreeDialog.tsx b/src/modules/energy/hydrogen-overview/components/OverviewDrillTreeDialog.tsx deleted file mode 100644 index 061f9a1..0000000 --- a/src/modules/energy/hydrogen-overview/components/OverviewDrillTreeDialog.tsx +++ /dev/null @@ -1,378 +0,0 @@ -import { ChevronDown, ChevronRight, Database, Download, Search, Truck, X } from 'lucide-react'; -import { Fragment, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; -import { createPortal } from 'react-dom'; -import * as XLSX from 'xlsx'; -import { SortableColumnHeader, sortBy, toggleSort, type SortDirection } from '../../components/SortableColumnHeader'; -import type { - HydrogenOverviewDetailGroup, - HydrogenOverviewDetailGroupBy, - HydrogenOverviewDetailRecord, - HydrogenOverviewDetailResponse, -} from '../../types'; -import type { HydrogenVehicleScope } from '../../api'; - -type Selection = { - stationId?: number | null; - customerId?: number | null; - customerName?: string | null; - plateNo?: string | null; -}; - -type TreeSortKey = 'name' | 'ownership' | 'source' | 'verify' | 'recordCount' | 'kg' | 'cost' | 'revenue'; - -interface OverviewDrillTreeDialogProps { - title: string; - initialVehicleScope: HydrogenVehicleScope; - initialSelection?: Selection; - load: ( - groupBy: HydrogenOverviewDetailGroupBy | null, - selection: Selection, - vehicleScope: HydrogenVehicleScope, - includeAll?: boolean, - ) => Promise; - onClose: () => void; -} - -const number = (value: number, digits = 2) => value.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits }); -const customerKey = (stationId: number | string, customer: HydrogenOverviewDetailGroup) => `${stationId}:${customer.id}:${customer.name}`; -const vehicleKey = (stationId: number | string, customer: HydrogenOverviewDetailGroup, vehicle: HydrogenOverviewDetailGroup) => `${customerKey(stationId, customer)}:${vehicle.name}`; - -/** 原型定义的四层账本树:加氢站 -> 客户 -> 车辆 -> 单笔订单与核对明细。 */ -export function OverviewDrillTreeDialog({ title, initialVehicleScope, initialSelection = {}, load, onClose }: OverviewDrillTreeDialogProps) { - const [root, setRoot] = useState(null); - const [customers, setCustomers] = useState>({}); - const [vehicles, setVehicles] = useState>({}); - const [orders, setOrders] = useState>({}); - const [openStations, setOpenStations] = useState>({}); - const [openCustomers, setOpenCustomers] = useState>({}); - const [openVehicles, setOpenVehicles] = useState>({}); - const [loading, setLoading] = useState('root'); - const [stationFilter, setStationFilter] = useState('all'); - const [customerFilter, setCustomerFilter] = useState('all'); - const [plateFilter, setPlateFilter] = useState('all'); - const [vehicleScope, setVehicleScope] = useState(initialVehicleScope); - const [exporting, setExporting] = useState(false); - const [sortKey, setSortKey] = useState('kg'); - const [sortDirection, setSortDirection] = useState('desc'); - - const loadRoot = useCallback(async (scope: HydrogenVehicleScope) => { - setLoading('root'); - setCustomers({}); - setVehicles({}); - setOrders({}); - setOpenStations({}); - setOpenCustomers({}); - setOpenVehicles({}); - try { - setRoot(await load('station', initialSelection, scope)); - } catch { - setRoot(null); - } finally { - setLoading(null); - } - }, [initialSelection, load]); - - useEffect(() => { void loadRoot(vehicleScope); }, [loadRoot, vehicleScope]); - - const toggleStation = async (station: HydrogenOverviewDetailGroup) => { - const key = String(station.id); - if (openStations[key]) { - setOpenStations(value => ({ ...value, [key]: false })); - return; - } - setOpenStations(value => ({ ...value, [key]: true })); - if (customers[key]) return; - setLoading(`station:${key}`); - try { - const data = await load('customer', { ...initialSelection, stationId: Number(station.id) || null }, vehicleScope); - setCustomers(value => ({ ...value, [key]: data.groups })); - } finally { - setLoading(null); - } - }; - - const toggleCustomer = async (stationId: number | string, customer: HydrogenOverviewDetailGroup) => { - const key = customerKey(stationId, customer); - if (openCustomers[key]) { - setOpenCustomers(value => ({ ...value, [key]: false })); - return; - } - setOpenCustomers(value => ({ ...value, [key]: true })); - if (vehicles[key]) return; - setLoading(`customer:${key}`); - try { - const data = await load('vehicle', { - ...initialSelection, - stationId: Number(stationId) || null, - customerId: Number(customer.id) || 0, - customerName: customer.name, - }, vehicleScope); - setVehicles(value => ({ ...value, [key]: data.groups })); - } finally { - setLoading(null); - } - }; - - const toggleVehicle = async (stationId: number | string, customer: HydrogenOverviewDetailGroup, vehicle: HydrogenOverviewDetailGroup) => { - const key = vehicleKey(stationId, customer, vehicle); - if (openVehicles[key]) { - setOpenVehicles(value => ({ ...value, [key]: false })); - return; - } - setOpenVehicles(value => ({ ...value, [key]: true })); - if (orders[key]) return; - setLoading(`vehicle:${key}`); - try { - const data = await load(null, { - ...initialSelection, - stationId: Number(stationId) || null, - customerId: Number(customer.id) || 0, - customerName: customer.name, - plateNo: vehicle.name, - }, vehicleScope); - setOrders(value => ({ ...value, [key]: data.records })); - } finally { - setLoading(null); - } - }; - - const stationOptions = root?.groups ?? []; - const selectedStation = stationFilter === 'all' ? null : stationOptions.find(station => String(station.id) === stationFilter) ?? null; - const customerOptions = selectedStation ? customers[String(selectedStation.id)] ?? [] : []; - const selectedCustomer = customerFilter === 'all' ? null : customerOptions.find(customer => customerKey(selectedStation?.id ?? '', customer) === customerFilter) ?? null; - const plateOptions = selectedStation && selectedCustomer ? vehicles[customerKey(selectedStation.id, selectedCustomer)] ?? [] : []; - const visibleStations = useMemo(() => { - const filtered = stationFilter === 'all' ? stationOptions : stationOptions.filter(station => String(station.id) === stationFilter); - return sortTreeGroups(filtered, sortKey, sortDirection); - }, [sortDirection, sortKey, stationFilter, stationOptions]); - - const selectStation = (value: string) => { - setStationFilter(value); - setCustomerFilter('all'); - setPlateFilter('all'); - const station = stationOptions.find(item => String(item.id) === value); - if (station && !openStations[String(station.id)]) void toggleStation(station); - }; - - const selectCustomer = (value: string) => { - setCustomerFilter(value); - setPlateFilter('all'); - const customer = customerOptions.find(item => customerKey(selectedStation?.id ?? '', item) === value); - if (selectedStation && customer && !openCustomers[value]) void toggleCustomer(selectedStation.id, customer); - }; - - const changeVehicleScope = (scope: HydrogenVehicleScope) => { - if (scope === vehicleScope) return; - setVehicleScope(scope); - setStationFilter('all'); - setCustomerFilter('all'); - setPlateFilter('all'); - }; - - const changeSort = (nextKey: TreeSortKey) => { - const next = toggleSort(sortKey, sortDirection, nextKey); - setSortKey(next.key); - setSortDirection(next.direction); - }; - - const exportAll = async () => { - setExporting(true); - try { - const detail = await load(null, { - ...initialSelection, - stationId: selectedStation ? Number(selectedStation.id) || null : null, - customerId: selectedCustomer ? Number(selectedCustomer.id) || 0 : null, - customerName: selectedCustomer?.name ?? null, - plateNo: plateFilter === 'all' ? null : plateFilter, - }, vehicleScope, true); - const rows = detail.records.map(record => ({ - 加氢时间: record.refuelTime, - 加氢站: record.stationName, - 客户: record.customerName, - 车牌: record.plateNo, - 车辆归属: record.vehicleScope === 'lingniu' ? '羚牛车辆' : '外部车辆', - 数据来源: record.source, - 核对状态: verifyText(record.verifyStatus), - 订单编号: record.orderNo, - 加氢量Kg: record.kg, - 成本元: record.cost, - 客户收入元: record.revenue, - })); - const workbook = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(rows), '穿透账单'); - XLSX.writeFile(workbook, `${title.replaceAll(/[\\/:*?"<>|]/g, '_')}_穿透账单.xlsx`); - if (detail.truncated) window.alert('当前筛选范围超过 20,000 笔,导出已截取前 20,000 笔。请收窄筛选范围后再次导出。'); - } finally { - setExporting(false); - } - }; - - if (typeof document === 'undefined') return null; - const summary = root?.summary; - return createPortal( -
{ if (event.target === event.currentTarget) onClose(); }}> -
-
-
-
-

{title}

-

加氢站 → 客户 → 车辆 → 单笔订单与核对明细

-
-
- -
-
-
- - - - -
- -
-
- ({ value: String(item.id), label: item.name }))} /> - ({ value: customerKey(selectedStation?.id ?? '', item), label: item.name }))} /> - ({ value: item.name, label: item.name }))} /> -
- {([{ key: 'all', label: '全部车辆' }, { key: 'lingniu', label: '仅羚牛车辆' }, { key: 'external', label: '仅外部车辆' }] as const).map(item => ( - - ))} -
-

提示:点击表格行可四级层层展开

-
- -
- -

左右滑动查看完整数据与凭证列

-
- - - - - - - - - - - - - - - {visibleStations.map(station => { - const stationKey = String(station.id); - return void toggleStation(station)} onCustomer={customer => void toggleCustomer(station.id, customer)} onVehicle={(customer, vehicle) => void toggleVehicle(station.id, customer, vehicle)} />; - })} - -
-
- {!root && loading === null ?
真实账本读取失败,请关闭后重试。
: null} -
-
-
, document.body, - ); -} - -function Metric({ label, value, tone = 'text-slate-900' }: { label: string; value: string; tone?: string }) { - return
{label}
{value}
; -} - -function SearchSelect({ allLabel, value, onChange, options, disabled = false }: { allLabel: string; value: string; onChange: (value: string) => void; options: { value: string; label: string }[]; disabled?: boolean }) { - const [open, setOpen] = useState(false); - const [keyword, setKeyword] = useState(''); - const selected = options.find(option => option.value === value); - const filtered = options.filter(option => option.label.toLowerCase().includes(keyword.trim().toLowerCase())); - const pick = (next: string) => { - onChange(next); - setOpen(false); - setKeyword(''); - }; - return
- - {open ?
- -
- - {filtered.map(option => )} - {filtered.length === 0 ?

暂无匹配结果

: null} -
-
: null} -
; -} - -function ExpandMark({ open }: { open: boolean }) { return open ? : ; } - -function StationBranch({ station, expanded, loadingKey, customers, customerFilter, plateFilter, openCustomers, vehicles, orders, openVehicles, sortKey, sortDirection, onStation, onCustomer, onVehicle }: { - station: HydrogenOverviewDetailGroup; expanded: boolean; loadingKey: string | null; customers: HydrogenOverviewDetailGroup[]; customerFilter: string; plateFilter: string; openCustomers: Record; vehicles: Record; orders: Record; openVehicles: Record; sortKey: TreeSortKey; sortDirection: SortDirection; onStation: () => void; onCustomer: (customer: HydrogenOverviewDetailGroup) => void; onVehicle: (customer: HydrogenOverviewDetailGroup, vehicle: HydrogenOverviewDetailGroup) => void; -}) { - const stationKey = String(station.id); - const visibleCustomers = sortTreeGroups(customerFilter === 'all' ? customers : customers.filter(customer => customerKey(station.id, customer) === customerFilter), sortKey, sortDirection); - return <> - {station.name}({station.customerCount} 家客户)} ownership="-" source="全量自动归集" verify="-" group={station} /> - {expanded && visibleCustomers.map(customer => { - const key = customerKey(stationKey, customer); - const childVehicles = vehicles[key] ?? []; - const visibleVehicles = sortTreeGroups(plateFilter === 'all' ? childVehicles : childVehicles.filter(vehicle => vehicle.name === plateFilter), sortKey, sortDirection); - return - onCustomer(customer)} indent={1} label={<>└─ 客户:{customer.name}} ownership="客户" source={`${childVehicles.length || '待'} 辆车挂载`} verify="-" group={customer} /> - {openCustomers[key] && visibleVehicles.map(vehicle => { - const key = vehicleKey(stationKey, customer, vehicle); - return - onVehicle(customer, vehicle)} indent={2} label={<>{vehicle.name}({vehicle.recordCount} 笔订单)} ownership={vehicle.vehicleScope === 'lingniu' ? '羚牛车辆' : '外部车辆'} source={vehicle.source ?? '未知来源'} verify={verifyText(vehicle.verifyStatus)} group={vehicle} /> - {openVehicles[key] && } - ; - })} - {openCustomers[key] && loadingKey === `customer:${key}` ? : null} - ; - })} - {expanded && loadingKey === `station:${stationKey}` ? : null} - ; -} - -function TreeRow({ label, ownership, source, verify, group, indent = 0, className, onClick }: { label: ReactNode; ownership: string; source: string; verify: string; group: HydrogenOverviewDetailGroup; indent?: number; className: string; onClick: () => void }) { - return {label}{ownership}{source}{verify === '-' ? - : }{group.recordCount} 笔{number(group.kg, 3)}{number(group.cost)}{number(group.revenue)}; -} - -function OrderRows({ orders, sortKey, sortDirection }: { orders: HydrogenOverviewDetailRecord[]; sortKey: TreeSortKey; sortDirection: SortDirection }) { - return <>{sortTreeOrders(orders, sortKey, sortDirection).map(order => └──订单编号{order.orderNo || order.id}({order.refuelTime})单价 ¥{order.costPrice.toFixed(2)}/Kg{order.source}1 笔{number(order.kg, 3)}{number(order.cost)}{number(order.revenue)})}; -} - -function sortTreeGroups(rows: HydrogenOverviewDetailGroup[], sortKey: TreeSortKey, sortDirection: SortDirection) { - return sortBy(rows, sortKey, sortDirection, (row, key) => { - if (key === 'ownership') return row.vehicleScope ?? ''; - if (key === 'verify') return row.verifyStatus ?? ''; - return row[key === 'name' ? 'name' : key] ?? ''; - }); -} - -function sortTreeOrders(rows: HydrogenOverviewDetailRecord[], sortKey: TreeSortKey, sortDirection: SortDirection) { - return sortBy(rows, sortKey, sortDirection, (row, key) => { - if (key === 'name') return row.orderNo || row.id; - if (key === 'ownership') return row.vehicleScope; - if (key === 'verify') return row.verifyStatus; - if (key === 'recordCount') return 1; - return row[key] ?? ''; - }); -} - -function LoadingRow({ text }: { text: string }) { return {text}; } - -function verifyText(value?: string) { - const normalized = (value ?? '').toUpperCase(); - if (normalized === 'VERIFIED' || normalized === 'PASS') return '已核对'; - if (normalized === 'PARTIAL') return '部分核对'; - if (normalized === 'FAILED' || normalized === 'REJECT') return '异常'; - return '未核对'; -} - -function VerifyTag({ value }: { value: string }) { - const color = value === '已核对' ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : value === '部分核对' ? 'border-amber-100 bg-amber-50 text-amber-700' : value === '异常' ? 'border-rose-100 bg-rose-50 text-rose-700' : 'border-slate-200 bg-slate-50 text-slate-500'; - return {value}; -} diff --git a/src/modules/energy/hydrogen-overview/components/OverviewHeader.tsx b/src/modules/energy/hydrogen-overview/components/OverviewHeader.tsx deleted file mode 100644 index 6a53c4a..0000000 --- a/src/modules/energy/hydrogen-overview/components/OverviewHeader.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { ChevronDown, RefreshCw, Truck } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; -import type { HydrogenVehicleScope } from '../../api'; -import { formatRefreshTime } from '../model'; - -interface BiYearSelectProps { - value: number; - years: number[]; - onChange: (year: number) => void; -} - -function BiYearSelect({ value, years, onChange }: BiYearSelectProps) { - const [isOpen, setIsOpen] = useState(false); - const ref = useRef(null); - - useEffect(() => { - function handleClickOutside(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) { - setIsOpen(false); - } - } - if (isOpen) { - document.addEventListener('mousedown', handleClickOutside); - } - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [isOpen]); - - return ( -
- - - {isOpen && ( -
-
切换数据年份
-
- {years.map((y) => ( - - ))} -
-
- )} -
- ); -} - -interface OverviewHeaderProps { - activeYear: number; - availableYears: number[]; - vehicleScope: HydrogenVehicleScope; - verifyScope?: 'all' | 'verified'; - onVerifyScopeChange?: (scope: 'all' | 'verified') => void; - selectedStationId?: number | null; - selectedStationName?: string | null; - stations?: { id: number; name: string }[]; - latestLedgerTime?: string | null; - lastRefreshAt?: number; - refreshing?: boolean; - onSelectYear: (year: number) => void; - onVehicleScopeChange: (scope: HydrogenVehicleScope) => void; - onStationChange?: (stationId: number | null) => void; - onRefresh: () => void; -} - -export function OverviewHeader({ - activeYear, - availableYears, - vehicleScope, - verifyScope = 'all', - onVerifyScopeChange, - latestLedgerTime, - lastRefreshAt = 0, - refreshing = false, - onSelectYear, - onVehicleScopeChange, - onRefresh, -}: OverviewHeaderProps) { - return ( -
-
-
- -
- - -
-
- -
-
- - - -
- - - {latestLedgerTime ? `账本 ${latestLedgerTime.slice(5, 16)}` : lastRefreshAt ? formatRefreshTime(lastRefreshAt) : '已同步'} - - - -
-
-
- ); -} diff --git a/src/modules/energy/hydrogen-overview/components/RefreshOverlay.tsx b/src/modules/energy/hydrogen-overview/components/RefreshOverlay.tsx deleted file mode 100644 index 4adcc93..0000000 --- a/src/modules/energy/hydrogen-overview/components/RefreshOverlay.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { AnimatePresence, motion } from 'motion/react'; - -interface RefreshOverlayProps { - refreshing: boolean; - hasData: boolean; -} - -export function RefreshOverlay({ refreshing, hasData }: RefreshOverlayProps) { - return ( - - {refreshing && hasData && ( - - - - )} - - ); -} diff --git a/src/modules/energy/hydrogen-overview/components/SummaryTables.tsx b/src/modules/energy/hydrogen-overview/components/SummaryTables.tsx deleted file mode 100644 index 6bb1797..0000000 --- a/src/modules/energy/hydrogen-overview/components/SummaryTables.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import type { HydrogenCustomerRow, HydrogenStationFull } from '../../types'; -import { useMemo, useState } from 'react'; -import { ChevronRight, Search } from 'lucide-react'; -import { SortableColumnHeader, sortBy, toggleSort, type SortDirection } from '../../components/SortableColumnHeader'; -import { - buildCustomerDrillPayload, - buildStationDrillPayload, - formatKg as fmtKg, - formatYuan as fmtYuan, - type OverviewDrillPayload, - type OverviewDrillRequest, - type OverviewScope, -} from '../model'; -import { OverviewDrillDialog } from './OverviewDrillDialog'; - -export function StationSummaryTable({ - stations, - onSelectStation, - scope = 'global', - scopeLabel, - onDrillRequest, -}: { - stations: HydrogenStationFull[]; - onSelectStation?: (stationId: number) => void; - scope?: OverviewScope; - scopeLabel?: string | null; - onDrillRequest?: (request: OverviewDrillRequest) => void; -}) { - const [province, setProvince] = useState('all'); - const [sortKey, setSortKey] = useState<'name' | 'province' | 'kg' | 'share' | 'revenue' | 'revenueShare'>('kg'); - const [sortDirection, setSortDirection] = useState('desc'); - const [drill, setDrill] = useState(null); - const [selectedStationId, setSelectedStationId] = useState(null); - const provinces = useMemo(() => [...new Set(stations.map(station => station.province?.trim()).filter(Boolean) as string[])], [stations]); - const filteredStations = province === 'all' - ? stations - : stations.filter(station => station.province === province); - const sortedStations = useMemo(() => sortBy(filteredStations, sortKey, sortDirection, (station, key) => station[key]), [filteredStations, sortDirection, sortKey]); - const changeSort = (nextKey: typeof sortKey) => { - const next = toggleSort(sortKey, sortDirection, nextKey); - setSortKey(next.key); - setSortDirection(next.direction); - }; - const openStation = (station: HydrogenStationFull) => { - setSelectedStationId(station.id); - if (onDrillRequest) onDrillRequest({ kind: 'station', key: String(station.id), label: station.name, entityId: station.id }); - else setDrill(buildStationDrillPayload(station)); - }; - return ( - <> - {stations.length > 0 && ( -
-
-
-
加氢站加氢汇总{scope === 'station' && scopeLabel ? ` · ${scopeLabel}` : ''}
-
- - {provinces.map(item => )} -
-
-
- 统计范围内共 {stations.length} 站 -
-
- -
- - - - - - - - - - - - - - {sortedStations.map((s, i) => { - const kgFmt = fmtKg(s.kg); - const revFmt = fmtYuan(s.revenue); - return ( - openStation(s)} style={{ cursor: 'pointer' }} title="查看加氢站汇总明细"> - - - - - - - - - ); - })} - -
#
{i + 1} - {s.name} 钻取 › - - - {s.province ?? '未归属'} - - - {kgFmt.value} {kgFmt.unit} - -
-
-
-
- {(s.share * 100).toFixed(1)}% -
-
- ¥{revFmt.value} {revFmt.unit} - -
-
-
-
- {(s.revenueShare * 100).toFixed(1)}% -
-
-
-
- )} - { setDrill(null); setSelectedStationId(null); }} - onPrimaryAction={selectedStationId && onSelectStation ? () => { const stationId = selectedStationId; setDrill(null); setSelectedStationId(null); onSelectStation(stationId); } : undefined} - /> - - ); -} - -export function CustomerSummaryTable({ - customers, - scope = 'global', - scopeLabel, - onDrillRequest, -}: { - customers: HydrogenCustomerRow[]; - scope?: OverviewScope; - scopeLabel?: string | null; - onDrillRequest?: (request: OverviewDrillRequest) => void; -}) { - const [sortKey, setSortKey] = useState<'name' | 'payer' | 'kg' | 'cost' | 'revenue'>('kg'); - const [sortDirection, setSortDirection] = useState('desc'); - const [drill, setDrill] = useState(null); - const sortedCustomers = useMemo(() => sortBy(customers, sortKey, sortDirection, (customer, key) => customer[key]), [customers, sortDirection, sortKey]); - const changeSort = (nextKey: typeof sortKey) => { - const next = toggleSort(sortKey, sortDirection, nextKey); - setSortKey(next.key); - setSortDirection(next.direction); - }; - const openCustomer = (customer: HydrogenCustomerRow, index: number) => { - if (onDrillRequest) onDrillRequest({ kind: 'customer', key: `${customer.name}-${index}`, label: customer.name }); - else setDrill(buildCustomerDrillPayload(customer)); - }; - return ( - <> - {customers.length > 0 && ( -
-
-
- - 客户账单汇总{scope === 'station' && scopeLabel ? ` · ${scopeLabel}` : ''} - - (已收 / 未收:等待客户能源账户和对账单打通后获取) - - - (已收未收打通中) - -
-
- Top {customers.length} -
-
-
- - - - - - - - - - - - - {sortedCustomers.map((c2, i) => { - const kgFmt = fmtKg(c2.kg); - const costFmt = fmtYuan(c2.cost); - const revFmt = fmtYuan(c2.revenue); - return ( - openCustomer(c2, i)} style={{ cursor: 'pointer' }} title="点击查看客户账单明细"> - - - - - - - - ); - })} - -
#
{i + 1} - {c2.name} 钻取 › - - {c2.payer === 'lingniu' ? ( - 羚牛 - ) : c2.payer === 'mixed' ? ( - 混合 - ) : ( - 客户 - )} - - {kgFmt.value} {kgFmt.unit} - - ¥{costFmt.value} {costFmt.unit} - - ¥{revFmt.value} {revFmt.unit} -
-
-
- )} - setDrill(null)} /> - - ); -} diff --git a/src/modules/energy/hydrogen-overview/model.test.ts b/src/modules/energy/hydrogen-overview/model.test.ts deleted file mode 100644 index d76ba5f..0000000 --- a/src/modules/energy/hydrogen-overview/model.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import type { HydrogenOverviewResponse } from '../api.js'; -import { - buildCustomerDrillPayload, - buildMetricDrillPayload, - buildMonthDrillPayload, - buildRegionDrillPayload, - buildStationDrillPayload, - deriveOverviewMetrics, - formatKg, - formatRelative, - formatYuan, -} from './model.js'; - -const drillKpi = { - yearKg: 10_000, - yearFee: 80_000, - yearRevenue: 75_000, - yearProfit: 15_000, - ourYearKg: 4_000, - ourYearFee: 20_000, - customerYearKg: 6_000, - monthKg: 1_000, - monthFee: 8_000, - monthRevenue: 7_500, - monthProfit: 1_500, - todayKg: 100, - todayFee: 800, - todayRevenue: 750, - todayProfit: 150, - lingniuBornKg: 0, - lingniuBornFee: 0, -}; - -test('重量和金额单位保持现有阈值及负数格式', () => { - assert.deepEqual(formatKg(999.5), { value: '999.50', unit: 'Kg' }); - assert.deepEqual(formatKg(1000), { value: '1.00', unit: 'T' }); - assert.deepEqual(formatYuan(9999), { value: '9,999', unit: '元' }); - assert.deepEqual(formatYuan(12345), { value: '1.23', unit: '万元' }); - assert.deepEqual(formatYuan(-100_000_000), { value: '-1.00', unit: '亿元' }); -}); - -test('相对更新时间保持秒、分钟、小时和未来时间边界', () => { - const now = new Date(2026, 7, 13, 12, 0, 0).getTime(); - assert.equal(formatRelative(now + 60_000, now), '刚刚'); - assert.equal(formatRelative(now - 30_000, now), '30 秒前'); - assert.equal(formatRelative(now - 5 * 60_000, now), '5 分钟前'); - assert.equal(formatRelative(now - 3 * 60 * 60_000, now), '3 小时前'); -}); - -test('总览派生指标保持月份顺序、动能、集中度和收益率口径', () => { - const data = { - kpi: { - yearKg: 1000, - yearFee: 0, - yearProfit: -50, - ourYearKg: 0, - customerYearKg: 0, - ourYearFee: 0, - yearRevenue: 500, - monthKg: 0, - monthFee: 0, - monthRevenue: 0, - monthProfit: 0, - todayKg: 0, - todayFee: 0, - todayRevenue: 0, - todayProfit: 0, - lingniuBornKg: 0, - lingniuBornFee: 0, - }, - top5: [{ id: 1, rank: 1, name: 'A', kg: 600, fee: 100, share: 0.6 }], - regions: [], - monthly: [ - { month: '2026-01', kg: 100, fee: 20, revenue: 30, profit: 10 }, - { month: '2026-02', kg: 150, fee: 30, revenue: 40, profit: 10 }, - ], - customers: [], - stations: [ - { id: 1, name: 'A', kg: 600, share: 0.6, revenue: 300, revenueShare: 0.6 }, - { id: 2, name: 'B', kg: 400, share: 0.4, revenue: 200, revenueShare: 0.4 }, - ], - availableYears: [2026], - year: 2026, - latestLedgerTime: '2026-08-17 21:50:09', - filter: { stationId: null, vehicleScope: 'all', verifyScope: 'all' }, - } satisfies HydrogenOverviewResponse; - - const metrics = deriveOverviewMetrics(data); - assert.equal(metrics.monthAvgKg, 125); - assert.equal(metrics.bestMonth?.month, '2026-02'); - assert.equal(metrics.latestMonth?.month, '2026-02'); - assert.equal(metrics.monthMomentum, 50); - assert.equal(metrics.top5Share, 60); - assert.equal(metrics.customerGrossMarginPct, -10); - assert.equal(metrics.stationAvgKg, 500); - assert.deepEqual(metrics.monthlyDual.map(item => item.monthLabel), ['1月', '2月']); -}); - -test('KPI 下钻只使用真实汇总口径且保留客户单毛利语义', () => { - const payload = buildMetricDrillPayload(drillKpi, 'yearProfit'); - assert.equal(payload.title, '客户单毛利'); - assert.equal(payload.metrics[0]?.label, '客户单毛利'); - assert.equal(payload.metrics[0]?.value, '¥1.5 万元'); - assert.equal(payload.rows.length, 0); - assert.match(payload.emptyMessage ?? '', /未返回站点、客户、车牌及单笔订单层级/); -}); - -test('月度下钻按接口返回的羚牛与外部车辆加氢量拆分', () => { - const payload = buildMonthDrillPayload({ - month: '2026-08', - kg: 1_000, - lingniuKg: 700, - externalKg: 300, - fee: 4_500, - revenue: 5_000, - profit: 500, - }); - assert.equal(payload.rows[0]?.category, '羚牛车辆'); - assert.equal(payload.rows[0]?.share, '70.0%'); - assert.equal(payload.rows[1]?.share, '30.0%'); - assert.equal(payload.metrics[3]?.label, '客户单毛利'); -}); - -test('省级区域下钻仅关联同省真实站点,市级缺少映射时明确空态', () => { - const stations = [ - { id: 1, name: '嘉兴站', province: '浙江', kg: 600, revenue: 1_000, share: 0.6, revenueShare: 0.5 }, - { id: 2, name: '广州站', province: '广东', kg: 400, revenue: 1_000, share: 0.4, revenueShare: 0.5 }, - ]; - const provincePayload = buildRegionDrillPayload({ region: '浙江', kg: 600, share: 0.6 }, stations, 'province'); - assert.equal(provincePayload.rows.length, 1); - assert.equal(provincePayload.rows[0]?.station, '嘉兴站'); - - const cityPayload = buildRegionDrillPayload({ region: '嘉兴', kg: 600, share: 0.6 }, stations, 'city'); - assert.equal(cityPayload.rows.length, 0); - assert.match(cityPayload.emptyMessage ?? '', /未返回城市字段/); -}); - -test('站点与客户弹层提供汇总指标及真实事件入口', () => { - const stationPayload = buildStationDrillPayload({ id: 9, name: '测试站', province: '广东', kg: 900, revenue: 3_000, share: 0.3, revenueShare: 0.4 }); - assert.equal(stationPayload.primaryActionLabel, '进入单站视图'); - assert.equal(stationPayload.metrics[0]?.value, '900.00 Kg'); - - const customerPayload = buildCustomerDrillPayload({ name: '测试客户', payer: 'customer', kg: 500, cost: 2_000, revenue: 2_500 }); - assert.equal(customerPayload.metrics[3]?.label, '价差'); - assert.equal(customerPayload.metrics[3]?.value, '¥500 元'); -}); diff --git a/src/modules/energy/hydrogen-overview/model.ts b/src/modules/energy/hydrogen-overview/model.ts deleted file mode 100644 index 34f005b..0000000 --- a/src/modules/energy/hydrogen-overview/model.ts +++ /dev/null @@ -1,298 +0,0 @@ -import type { HydrogenOverviewResponse } from '../api'; -import type { - HydrogenCustomerRow, - HydrogenKpi, - HydrogenMonthlyPoint, - HydrogenRegionShare, - HydrogenStationFull, -} from '../types'; - -export type OverviewScope = 'global' | 'station'; -export type OverviewMetricKey = 'yearKg' | 'yearFee' | 'yearProfit' | 'monthKg' | 'todayKg'; -export type OverviewDrillKind = 'metric' | 'month' | 'station' | 'customer' | 'region'; - -export interface OverviewDrillRequest { - kind: OverviewDrillKind; - key: string; - label: string; - entityId?: number; -} - -export interface OverviewDrillMetric { - label: string; - value: string; - tone?: 'default' | 'blue' | 'green' | 'amber' | 'red'; -} - -export interface OverviewDrillColumn { - key: string; - label: string; - align?: 'left' | 'right'; -} - -export interface OverviewDrillPayload { - kind: OverviewDrillKind; - title: string; - subtitle: string; - metrics: OverviewDrillMetric[]; - columns: OverviewDrillColumn[]; - rows: Record[]; - emptyMessage?: string; - primaryActionLabel?: string; - groupBy?: 'station' | 'customer' | 'vehicle'; - groupByOptions?: Array<'station' | 'customer' | 'vehicle'>; - rowActionLabel?: string; -} - -export function formatKg(kg: number): { value: string; unit: string } { - if (kg >= 1000) return { value: (kg / 1000).toFixed(2), unit: 'T' }; - return { value: kg.toFixed(2), unit: 'Kg' }; -} - -export function formatYuan(yuan: number): { value: string; unit: string } { - const absolute = Math.abs(yuan); - if (absolute >= 100_000_000) { - return { value: (yuan / 100_000_000).toFixed(2), unit: '亿元' }; - } - if (absolute >= 10_000) { - return { - value: (yuan / 10_000).toLocaleString('zh-CN', { maximumFractionDigits: 2 }), - unit: '万元', - }; - } - return { - value: yuan.toLocaleString('zh-CN', { maximumFractionDigits: 0 }), - unit: '元', - }; -} - -function formatKgText(value: number): string { - const formatted = formatKg(value); - return `${formatted.value} ${formatted.unit}`; -} - -function formatYuanText(value: number): string { - const formatted = formatYuan(value); - return `¥${formatted.value} ${formatted.unit}`; -} - -const METRIC_LABELS: Record = { - yearKg: '累计加氢量', - yearFee: '累计加氢费', - yearProfit: '客户单毛利', - monthKg: '本月加氢', - todayKg: '本日加氢', -}; - -/** - * 总览接口只返回聚合值。弹层先完整呈现可核验汇总,订单树由父页面在接入 - * 明细接口后通过 onDrillRequest 承接,避免用推算值冒充真实订单。 - */ -export function buildMetricDrillPayload(kpi: HydrogenKpi, key: OverviewMetricKey): OverviewDrillPayload { - const customerCost = Math.max(0, kpi.yearFee - kpi.ourYearFee); - const common = { - kind: 'metric' as const, - title: METRIC_LABELS[key], - subtitle: '汇总口径穿透 · 当前筛选范围', - columns: [], - rows: [], - emptyMessage: '当前总览接口未返回站点、客户、车牌及单笔订单层级;汇总值已按真实账本展示。', - }; - - if (key === 'yearKg') { - return { - ...common, - metrics: [ - { label: '累计加氢量', value: formatKgText(kpi.yearKg), tone: 'blue' }, - { label: '我司车辆', value: formatKgText(kpi.ourYearKg) }, - { label: '客户车辆', value: formatKgText(kpi.customerYearKg) }, - ], - }; - } - if (key === 'yearFee') { - return { - ...common, - metrics: [ - { label: '累计加氢费', value: formatYuanText(kpi.yearFee), tone: 'blue' }, - { label: '我司承担', value: formatYuanText(kpi.ourYearFee) }, - { label: '客户承担', value: formatYuanText(customerCost) }, - ], - }; - } - if (key === 'yearProfit') { - return { - ...common, - metrics: [ - { label: '客户单毛利', value: formatYuanText(kpi.yearProfit), tone: kpi.yearProfit >= 0 ? 'green' : 'red' }, - { label: '客户收入', value: formatYuanText(kpi.yearRevenue) }, - { label: '客户成本', value: formatYuanText(customerCost) }, - ], - }; - } - if (key === 'monthKg') { - return { - ...common, - metrics: [ - { label: '本月加氢量', value: formatKgText(kpi.monthKg), tone: 'amber' }, - { label: '本月加氢费', value: formatYuanText(kpi.monthFee) }, - { label: '占年比', value: `${kpi.yearKg > 0 ? (kpi.monthKg / kpi.yearKg * 100).toFixed(1) : '0.0'}%` }, - ], - }; - } - return { - ...common, - metrics: [ - { label: '本日加氢量', value: formatKgText(kpi.todayKg), tone: 'blue' }, - { label: '本日加氢费', value: formatYuanText(kpi.todayFee) }, - { label: '占月比', value: `${kpi.monthKg > 0 ? (kpi.todayKg / kpi.monthKg * 100).toFixed(1) : '0.0'}%` }, - ], - }; -} - -export function buildMonthDrillPayload(month: HydrogenMonthlyPoint): OverviewDrillPayload { - const lingniuKg = month.lingniuKg ?? 0; - const externalKg = month.externalKg ?? Math.max(0, month.kg - lingniuKg); - return { - kind: 'month', - title: `${month.month} 月度经营明细`, - subtitle: '月度聚合 · 当前车辆范围', - metrics: [ - { label: '加氢总量', value: formatKgText(month.kg), tone: 'blue' }, - { label: '成本支出', value: formatYuanText(month.fee), tone: 'amber' }, - { label: '客户收入', value: formatYuanText(month.revenue), tone: 'green' }, - { label: '客户单毛利', value: formatYuanText(month.profit), tone: month.profit >= 0 ? 'green' : 'red' }, - ], - columns: [ - { key: 'category', label: '车辆范围' }, - { key: 'kg', label: '加氢量', align: 'right' }, - { key: 'share', label: '占比', align: 'right' }, - ], - rows: [ - { category: '羚牛车辆', kg: formatKgText(lingniuKg), share: `${month.kg > 0 ? (lingniuKg / month.kg * 100).toFixed(1) : '0.0'}%` }, - { category: '外部车辆', kg: formatKgText(externalKg), share: `${month.kg > 0 ? (externalKg / month.kg * 100).toFixed(1) : '0.0'}%` }, - ], - }; -} - -export function buildStationDrillPayload(station: HydrogenStationFull): OverviewDrillPayload { - return { - kind: 'station', - title: `「${station.name}」加氢汇总明细`, - subtitle: `${station.province || '未归属省份'} · 当前统计周期`, - metrics: [ - { label: '加氢量', value: formatKgText(station.kg), tone: 'blue' }, - { label: '氢费收入', value: formatYuanText(station.revenue), tone: 'green' }, - { label: '加氢量占比', value: `${(station.share * 100).toFixed(1)}%` }, - { label: '收入占比', value: `${(station.revenueShare * 100).toFixed(1)}%` }, - ], - columns: [], - rows: [], - emptyMessage: '当前总览接口未返回该站按日、客户及车辆流水;可切换到单站视图继续查看真实数据。', - primaryActionLabel: '进入单站视图', - }; -} - -export function buildCustomerDrillPayload(customer: HydrogenCustomerRow): OverviewDrillPayload { - const payerLabel = customer.payer === 'lingniu' ? '羚牛承担' : customer.payer === 'mixed' ? '混合承担' : '客户承担'; - return { - kind: 'customer', - title: `「${customer.name}」客户账单明细`, - subtitle: `${payerLabel} · 当前统计周期`, - metrics: [ - { label: '加氢量', value: formatKgText(customer.kg), tone: 'blue' }, - { label: '成本支出', value: formatYuanText(customer.cost), tone: 'amber' }, - { label: '应收', value: formatYuanText(customer.revenue), tone: 'green' }, - { label: '价差', value: formatYuanText(customer.revenue - customer.cost), tone: customer.revenue - customer.cost >= 0 ? 'green' : 'red' }, - ], - columns: [], - rows: [], - emptyMessage: '当前总览接口未返回该客户按日、车牌及单笔加氢流水。', - }; -} - -export function buildRegionDrillPayload( - region: HydrogenRegionShare, - stations: HydrogenStationFull[], - granularity: 'province' | 'city', -): OverviewDrillPayload { - const matchedStations = granularity === 'province' - ? stations.filter(station => (station.province?.trim() || '未归属') === region.region) - : []; - return { - kind: 'region', - title: `${region.region}加氢区域明细`, - subtitle: `${granularity === 'province' ? '省级' : '市级'}口径 · 当前统计周期`, - metrics: [ - { label: '区域加氢量', value: formatKgText(region.kg), tone: 'blue' }, - { label: '全局占比', value: `${(region.share * 100).toFixed(1)}%` }, - { label: '已关联站点', value: `${matchedStations.length} 站` }, - ], - columns: [ - { key: 'station', label: '加氢站' }, - { key: 'province', label: '所属省份' }, - { key: 'kg', label: '加氢量', align: 'right' }, - { key: 'share', label: '区域内占比', align: 'right' }, - ], - rows: matchedStations.map(station => ({ - station: station.name, - province: station.province || '未归属', - kg: formatKgText(station.kg), - share: `${region.kg > 0 ? (station.kg / region.kg * 100).toFixed(1) : '0.0'}%`, - })), - emptyMessage: granularity === 'city' - ? '当前站点汇总接口未返回城市字段,无法将市级汇总继续关联到具体站点。' - : '当前区域暂无可关联站点。', - }; -} - -export function formatRelative(timestamp: number, now = Date.now()): string { - const seconds = Math.max(0, Math.floor((now - timestamp) / 1000)); - if (seconds < 5) return '刚刚'; - if (seconds < 60) return `${seconds} 秒前`; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes} 分钟前`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours} 小时前`; - return new Date(timestamp).toLocaleString('zh-CN', { hour12: false }); -} - -export function formatRefreshTime(timestamp: number, now = Date.now()): string { - const exactTime = new Date(timestamp).toLocaleString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - hour12: false, - }); - return `${formatRelative(timestamp, now)} · ${exactTime.replace(/\//g, '-')}`; -} - -export function deriveOverviewMetrics(data: HydrogenOverviewResponse) { - const { kpi, monthly, stations, top5 } = data; - const monthAvgKg = monthly.length > 0 - ? monthly.reduce((sum, month) => sum + month.kg, 0) / monthly.length - : 0; - const bestMonth = monthly.reduce( - (best, item) => (!best || item.kg > best.kg ? item : best), - null, - ); - const latestMonth = monthly[monthly.length - 1]; - const previousMonth = monthly[monthly.length - 2]; - - return { - monthAvgKg, - bestMonth, - latestMonth, - monthMomentum: latestMonth && previousMonth && previousMonth.kg > 0 - ? ((latestMonth.kg - previousMonth.kg) / previousMonth.kg) * 100 - : null, - top5Share: (top5.reduce((sum, item) => sum + item.kg, 0) / Math.max(1, kpi.yearKg)) * 100, - customerGrossMarginPct: kpi.yearRevenue > 0 ? (kpi.yearProfit / kpi.yearRevenue) * 100 : 0, - stationAvgKg: stations.length > 0 ? kpi.yearKg / stations.length : 0, - monthlyDual: monthly.map(month => ({ - ...month, - monthLabel: `${month.month.slice(5).replace(/^0/, '')}月`, - })), - }; -} diff --git a/src/modules/energy/styles/energy-bi-board.css b/src/modules/energy/styles/energy-bi-board.css deleted file mode 100644 index d2d4ad7..0000000 --- a/src/modules/energy/styles/energy-bi-board.css +++ /dev/null @@ -1,4907 +0,0 @@ -/** - * 能源 BI 宿主设计令牌(来源:AI-羚牛氢能-能源BI-complete.zip · #hydrogen/overview) - * 本原型独立嵌入 bi-next,禁止引入 OneOS V2 组件; - * 字体例外:汉字/UI 与数字等宽对齐 V2 Token(DESIGN §2.2)。 - */ - -/* —— 访问口令门(轻门禁 · 对齐宿主蓝系,非 V2 组件) —— */ -.ehb-gate { - --bi-text: #0f172a; - --bi-muted: #64748b; - --bi-blue: #2563eb; - --bi-line: rgba(15, 23, 42, 0.08); - --bi-danger: #dc2626; - min-height: 100vh; - display: flex; - align-items: center; - justify-content: center; - padding: 24px; - background: - radial-gradient( - 1000px 380px at 15% -5%, - rgba(37, 99, 235, 0.08), - transparent 50% - ), - linear-gradient(160deg, #eff6ff 0%, #f8fafc 45%, #ffffff 100%); - color: var(--bi-text); - font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", - "Microsoft YaHei", "Noto Sans SC", sans-serif; - box-sizing: border-box; -} - -.ehb-gate *, -.ehb-gate *::before, -.ehb-gate *::after { - box-sizing: border-box; -} - -.ehb-gate-card { - width: min(420px, 100%); - padding: 36px 32px 28px; - border: 1px solid var(--bi-line); - border-radius: 16px; - background: #ffffff; - box-shadow: 0 12px 40px rgba(15, 23, 42, 0.08); -} - -.ehb-gate-kicker { - font-size: 11px; - letter-spacing: 0.12em; - color: var(--bi-blue); - margin: 0 0 16px; - font-weight: 600; -} - -.ehb-gate-title { - margin: 0 0 8px; - font-size: 24px; - font-weight: 800; - color: var(--bi-text); -} - -.ehb-gate-sub { - margin: 0 0 24px; - font-size: 13px; - line-height: 1.55; - color: var(--bi-muted); -} - -.ehb-gate-label { - display: block; - font-size: 12px; - font-weight: 600; - color: var(--bi-muted); - margin-bottom: 8px; -} - -.ehb-gate-input { - display: block; - width: 100%; - height: 44px; - min-height: 44px; - border-radius: 10px; - border: 1px solid var(--bi-line); - background: #f8fafc; - color: var(--bi-text); - padding: 0 14px; - font-size: 16px; - line-height: 44px; - appearance: none; - -webkit-appearance: none; -} - -.ehb-gate-input::placeholder { - color: #94a3b8; -} - -.ehb-gate-input:hover { - border-color: #cbd5e1; -} - -.ehb-gate-input:focus { - outline: none; - border-color: var(--bi-blue); - background: #fff; - box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18); -} - -.ehb-gate-error { - min-height: 20px; - margin: 8px 0 12px; - font-size: 12px; - color: var(--bi-danger); -} - -.ehb-gate-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 100%; - height: 44px; - min-height: 44px; - border: none; - border-radius: 10px; - background: var(--bi-blue); - color: #fff; - font-size: 15px; - font-weight: 700; - cursor: pointer; -} - -.ehb-gate-btn:hover { - background: #1d4ed8; -} - -.ehb-gate-btn:focus-visible { - outline: 2px solid #1e40af; - outline-offset: 2px; -} - -.ehb-gate-foot { - margin: 16px 0 0; - font-size: 11px; - color: #64748b; - text-align: center; -} - -.ehb-shell { - --bi-app-bg: #f8fafc; - --bi-panel: #ffffff; - --bi-hairline: rgba(15, 23, 42, 0.08); - --bi-hairline-subtle: rgba(15, 23, 42, 0.04); - --bi-text: #0f172a; - --bi-text-body: #1e293b; - --bi-text-sub: #334155; - --bi-muted: #64748b; - --bi-tertiary: #94a3b8; - --bi-blue: #2563eb; - --bi-blue-soft: #eff6ff; - --bi-green: #059669; - --bi-amber: #d97706; - --bi-red: #dc2626; - --bi-purple: #7c3aed; - --bi-cyan: #0891b2; - --bi-rail: #0f172a; - --bi-shadow: - 0 1px 2px rgba(15, 23, 42, 0.04), 0 4px 16px rgba(15, 23, 42, 0.03); - --bi-shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04); - --bi-radius: 14px; - --bi-radius-sm: 10px; - --bi-font: - -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", - "Microsoft YaHei", "Noto Sans SC", sans-serif; - --bi-font-mono: - "JetBrains Mono", "Cascadia Mono", "Cascadia Code", Consolas, "SF Mono", - SFMono-Regular, Menlo, "Courier New", monospace; - - display: flex; - min-height: 100vh; - background: - radial-gradient( - 1000px 380px at 15% -5%, - rgba(37, 99, 235, 0.04), - transparent 50% - ), - var(--bi-app-bg); - color: var(--bi-text-body); - font-family: var(--bi-font); - font-variant-numeric: tabular-nums; -} - -.ehb-rail { - width: 72px; - flex-shrink: 0; - background: var(--bi-rail); - color: #e2e8f0; - display: flex; - flex-direction: column; - align-items: center; - padding: 16px 0; - gap: 8px; -} - -.ehb-rail__item { - width: 56px; - border: none; - background: transparent; - color: #94a3b8; - border-radius: 10px; - padding: 10px 4px; - cursor: pointer; - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - font-size: 11px; - font-weight: 500; - transition: - background 0.15s ease, - color 0.15s ease; -} - -.ehb-rail__item.is-active { - background: var(--bi-blue); - color: #fff; - font-weight: 600; -} - -.ehb-rail__item:disabled { - opacity: 0.35; - cursor: not-allowed; -} - -.ehb-body { - flex: 1; - min-width: 0; - padding: 18px 22px 36px; - box-sizing: border-box; -} - -/* 总览关联入口:与在线原型的页头下双卡保持相同桌面密度。 */ -.ehb-related-modules { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; - margin: 0 0 12px; -} - -.ehb-related-card { - display: flex; - min-width: 0; - min-height: 102px; - flex-direction: column; - align-items: flex-start; - border: 1px solid #dbe7ef; - border-radius: 12px; - background: #fff; - padding: 15px 16px; - color: var(--bi-text); - text-align: left; - box-shadow: var(--bi-shadow-sm); - cursor: pointer; - transition: - border-color 0.15s ease, - box-shadow 0.15s ease; -} - -.ehb-related-card:hover { - border-color: #93c5fd; - box-shadow: var(--bi-shadow); -} - -.ehb-related-card__title { - font-size: 14px; - font-weight: 700; - line-height: 1.35; -} - -.ehb-related-card__body { - margin-top: 7px; - color: var(--bi-muted); - font-size: 12px; - line-height: 1.45; -} - -.ehb-related-card__cta { - margin-top: auto; - padding-top: 8px; - color: #0369a1; - font-size: 12px; - font-weight: 600; -} - -/* —— 页头 —— */ -.ehb-chrome { - position: sticky; - top: 0; - z-index: 20; - display: flex; - flex-wrap: wrap; - align-items: flex-end; - justify-content: space-between; - gap: 12px 16px; - margin: -6px -6px 16px; - padding: 10px 6px 12px; - background: rgba(248, 250, 252, 0.92); - backdrop-filter: blur(8px); - border-bottom: 1px solid var(--bi-hairline-subtle); -} - -.ehb-chrome__lead h1 { - margin: 2px 0 0; - font-size: 22px; - font-weight: 700; - color: var(--bi-text); - letter-spacing: -0.02em; - line-height: 1.2; -} - -.ehb-crumb { - font-size: 12px; - color: var(--bi-muted); - font-weight: 400; -} - -.ehb-chrome__tools { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 10px; -} - -.ehb-chrome__clock { - font-size: 12px; - color: var(--bi-tertiary); - font-family: var(--bi-font-mono); - font-variant-numeric: tabular-nums; -} - -.ehb-seg { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 3px; - background: #f1f5f9; - border-radius: 10px; - padding: 3px; - min-width: 150px; -} - -.ehb-seg button { - border: none; - background: transparent; - border-radius: 7px; - height: 30px; - padding: 0 12px; - font-size: 12px; - font-weight: 500; - color: var(--bi-muted); - cursor: pointer; - transition: - background 0.15s ease, - color 0.15s ease; -} - -.ehb-seg button.is-active { - background: #ffffff; - color: var(--bi-blue); - font-weight: 600; - box-shadow: var(--bi-shadow-sm); -} - -.ehb-year-select-wrapper { - position: relative; - display: inline-block; -} - -.ehb-year-select-btn { - display: inline-flex; - align-items: center; - gap: 6px; - height: 28px; - padding: 0 12px; - background: #ffffff; - border: 1px solid var(--bi-hairline); - border-radius: 999px; - font-size: 12px; - font-weight: 600; - color: #1e293b; - font-family: var(--bi-font-mono); - cursor: pointer; - box-shadow: var(--bi-shadow-sm); - transition: all 0.15s ease; -} - -.ehb-year-select-btn:hover, -.ehb-year-select-btn.is-active { - border-color: #0284c7; - color: #0284c7; - box-shadow: 0 0 0 2px rgba(2, 132, 199, 0.12); -} - -.ehb-year-dropdown { - position: absolute; - top: calc(100% + 6px); - right: 0; - z-index: 1000; - width: 140px; - background: #ffffff; - border: 1px solid #cbd5e1; - border-radius: 10px; - padding: 6px; - box-shadow: - 0 10px 25px -5px rgba(0, 0, 0, 0.12), - 0 8px 10px -6px rgba(0, 0, 0, 0.08); - font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; -} - -.ehb-year-dropdown__header { - padding: 4px 8px 6px; - font-size: 11px; - font-weight: 600; - color: #94a3b8; - border-bottom: 1px solid #f1f5f9; - margin-bottom: 4px; -} - -.ehb-year-dropdown__list { - display: flex; - flex-direction: column; - gap: 2px; - max-height: 200px; - overflow-y: auto; -} - -.ehb-year-dropdown__item { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - padding: 6px 10px; - border: none; - background: transparent; - border-radius: 6px; - font-size: 12px; - font-family: var(--bi-font-mono); - font-weight: 500; - color: #334155; - cursor: pointer; - transition: all 0.12s ease; -} - -.ehb-year-dropdown__item:hover { - background: #f1f5f9; - color: #0284c7; -} - -.ehb-year-dropdown__item.is-selected { - background: #e0f2fe; - color: #0284c7; - font-weight: 700; -} - -.ehb-year-check { - font-size: 12px; - font-weight: 700; - color: #0284c7; -} - -.ehb-btn { - display: inline-flex; - align-items: center; - gap: 6px; - height: 30px; - padding: 0 11px; - border-radius: 8px; - border: 1px solid var(--bi-hairline); - background: #ffffff; - color: var(--bi-text-body); - font-size: 12px; - font-weight: 500; - cursor: pointer; - transition: - border-color 0.15s ease, - color 0.15s ease; -} - -.ehb-btn:hover { - border-color: rgba(37, 99, 235, 0.35); - color: var(--bi-blue); -} - -.ehb-btn:focus-visible, -.ehb-chip:focus-visible, -.ehb-seg button:focus-visible, -.ehb-year button:focus-visible, -.ehb-dim:focus-visible, -.ehb-dim__sub:focus-visible, -.ehb-stats__tabs button:focus-visible { - outline: 2px solid rgba(37, 99, 235, 0.4); - outline-offset: 1px; -} - -.ehb-btn--ghost { - background: transparent; - color: var(--bi-muted); -} - -.ehb-chip-group { - display: inline-flex; - flex-wrap: wrap; - gap: 4px; - padding: 3px; - border-radius: 999px; - background: #f1f5f9; -} - -.ehb-chip { - height: 26px; - padding: 0 11px; - border-radius: 999px; - border: 1px solid transparent; - background: transparent; - font-size: 12px; - font-weight: 500; - color: var(--bi-muted); - cursor: pointer; - transition: - background 0.15s ease, - color 0.15s ease; -} - -.ehb-chip.is-active { - background: #ffffff; - color: var(--bi-blue); - font-weight: 600; - box-shadow: var(--bi-shadow-sm); -} - -.ehb-filters { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; -} - -.ehb-filters__rule { - width: 1px; - height: 18px; - background: var(--bi-hairline); -} - -/* —— 宿主总览区(V3 双层卡) —— */ -.ehb-host { - margin-bottom: 20px; - padding: 14px; - background: rgba(255, 255, 255, 0.6); - border: 1px dashed rgba(148, 163, 184, 0.3); - border-radius: var(--bi-radius); -} - -.ehb-host-kpi { - display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: 10px; - margin-bottom: 10px; -} - -.ehb-kpi-dual { - background: #ffffff; - width: 100%; - border-radius: var(--bi-radius-sm); - border: 1px solid var(--bi-hairline); - padding: 10px; - display: flex; - flex-direction: column; - box-sizing: border-box; - text-align: left; - color: inherit; - font: inherit; -} - -.ehb-kpi-dual__head { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 4px; -} - -.ehb-kpi-dual__label { - font-size: 12px; - font-weight: 500; - color: var(--bi-muted); -} - -.ehb-kpi-dual__badge { - width: 22px; - height: 22px; - border-radius: 6px; - display: grid; - place-items: center; - flex-shrink: 0; -} - -.ehb-kpi-dual__badge.is-blue { - background: var(--bi-blue-soft); - color: var(--bi-blue); -} -.ehb-kpi-dual__badge.is-green { - background: #ecfdf5; - color: var(--bi-green); -} -.ehb-kpi-dual__badge.is-amber { - background: #fffbeb; - color: var(--bi-amber); -} -.ehb-kpi-dual__badge.is-purple { - background: #f3e8ff; - color: var(--bi-purple); -} -.ehb-kpi-dual__badge.is-cyan { - background: #ecfeff; - color: var(--bi-cyan); -} - -.ehb-kpi-dual__val { - display: flex; - align-items: baseline; - color: var(--bi-text-body); - font-family: var(--bi-font-mono); - font-variant-numeric: tabular-nums; - line-height: 1.2; - margin-bottom: 6px; -} - -.ehb-kpi-dual__symbol { - font-size: 13px; - font-weight: 600; - color: var(--bi-muted); - margin-right: 2px; -} - -.ehb-kpi-dual__num { - font-size: 20px; - font-weight: 700; - letter-spacing: -0.02em; -} - -.ehb-kpi-dual__unit { - font-size: 12px; - font-weight: 500; - color: var(--bi-muted); - margin-left: 2px; -} - -.ehb-kpi-dual__deck { - background: #f8fafc; - border-radius: 6px; - padding: 4px 8px; - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - column-gap: 10px; - align-items: center; - font-size: 11px; - color: var(--bi-muted); - font-family: var(--bi-font-mono); - font-variant-numeric: tabular-nums; -} - -.ehb-kpi-dual__deck > span { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.ehb-kpi-dual__deck > span:last-child { - text-align: right; -} - -/* 三段承担口径:名称和数值分行,避免长数值被省略。 */ -.ehb-kpi-dual__deck.is-triple { - grid-template-columns: repeat(3, minmax(0, 1fr)); - column-gap: 6px; - padding: 6px 8px; -} - -.ehb-kpi-dual__deck.is-triple > .ehb-kpi-dual__detail { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 1px; - min-width: 0; - overflow: visible; - white-space: nowrap; - text-align: left; -} - -.ehb-kpi-dual__detail-label { - font-family: var(--bi-font-sans); - font-size: 10px; - color: #64748b; - line-height: 1.25; -} - -.ehb-kpi-dual__detail-value { - font-size: 11px; - color: #334155; - font-weight: 700; - line-height: 1.3; -} - -.ehb-insight { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 10px; -} - -.ehb-insight__card { - background: #ffffff; - border-radius: var(--bi-radius-sm); - border: 1px solid var(--bi-hairline); - padding: 10px 12px; - display: flex; - gap: 10px; - align-items: flex-start; -} - -.ehb-insight__icon { - width: 32px; - height: 32px; - border-radius: 8px; - background: var(--bi-blue-soft); - color: var(--bi-blue); - display: grid; - place-items: center; - flex-shrink: 0; -} - -.ehb-insight__icon.is-down { - background: #fef2f2; - color: var(--bi-red); -} - -.ehb-insight__icon.is-ok { - background: #ecfdf5; - color: var(--bi-green); -} - -.ehb-insight__title { - font-size: 11px; - color: var(--bi-muted); - font-weight: 500; -} - -.ehb-insight__value { - font-size: 18px; - font-weight: 700; - margin-top: 1px; - color: var(--bi-text-body); - font-family: var(--bi-font-mono); - font-variant-numeric: tabular-nums; -} - -.ehb-insight__value.is-neg { - color: var(--bi-red); -} -.ehb-insight__value.is-pos { - color: var(--bi-green); -} - -.ehb-insight__desc { - font-size: 11px; - color: var(--bi-tertiary); - margin-top: 2px; - line-height: 1.35; -} - -.ehb-insight__card--rank { - position: relative; - align-items: center; -} - -.ehb-insight__card--rank.is-open { - border-color: #7dd3fc; - box-shadow: 0 0 0 2px rgba(2, 132, 199, 0.12); - z-index: 30; -} - -.ehb-insight__rank-body { - flex: 1; - min-width: 0; -} - -.ehb-insight__rank-chevron { - flex-shrink: 0; - color: var(--bi-muted); - transition: transform 0.2s ease; - margin-left: auto; -} - -.ehb-insight__rank-chevron.is-open { - transform: rotate(180deg); - color: #0284c7; -} - -.ehb-station-rank-dropdown { - position: absolute; - top: calc(100% + 6px); - left: 0; - right: 0; - min-width: 360px; - max-width: min(520px, 92vw); - background: #ffffff; - border: 1px solid #e2e8f0; - border-radius: 10px; - box-shadow: 0 12px 32px rgba(15, 23, 42, 0.14); - z-index: 40; - overflow: hidden; -} - -.ehb-station-rank-dropdown__head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 10px 12px; - border-bottom: 1px solid #e2e8f0; - font-size: 12px; - font-weight: 700; - color: #0f172a; -} - -.ehb-station-rank-dropdown__meta { - font-size: 11px; - font-weight: 500; - color: #64748b; -} - -.ehb-station-rank-dropdown__list { - max-height: 320px; - overflow-y: auto; - padding: 6px; - -webkit-overflow-scrolling: touch; -} - -.ehb-station-rank-item { - display: grid; - grid-template-columns: 28px minmax(0, 1fr) auto auto; - align-items: center; - gap: 8px; - width: 100%; - border: none; - background: transparent; - padding: 8px 8px; - border-radius: 8px; - cursor: pointer; - text-align: left; -} - -.ehb-station-rank-item:hover { - background: #f0f9ff; -} - -.ehb-station-rank-item__rank { - width: 22px; - height: 22px; - border-radius: 6px; - display: grid; - place-items: center; - font-size: 11px; - font-weight: 700; - font-family: var(--bi-font-mono); - color: #64748b; - background: #f1f5f9; -} - -.ehb-station-rank-item__rank.is-top { - color: #fff; - background: #0284c7; -} - -.ehb-station-rank-item__main { - min-width: 0; - display: flex; - flex-direction: column; - gap: 4px; -} - -.ehb-station-rank-item__name { - font-size: 12px; - color: #0f172a; - font-weight: 600; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.ehb-station-rank-item__bar { - height: 4px; - border-radius: 999px; - background: #e2e8f0; - overflow: hidden; -} - -.ehb-station-rank-item__bar > span { - display: block; - height: 100%; - border-radius: inherit; - background: linear-gradient(90deg, #38bdf8, #0284c7); -} - -.ehb-station-rank-item__val { - font-size: 12px; - font-weight: 700; - font-family: var(--bi-font-mono); - font-variant-numeric: tabular-nums; - color: #0f172a; - white-space: nowrap; -} - -.ehb-station-rank-item__share { - font-size: 11px; - color: #64748b; - font-family: var(--bi-font-mono); - font-variant-numeric: tabular-nums; - min-width: 42px; - text-align: right; -} - -.ehb-station-rank-empty { - padding: 20px 12px; - text-align: center; - font-size: 12px; - color: #94a3b8; -} - -/* —— 核心区:我司成本 —— */ -.ehb-feature { - background: #ffffff; - border-radius: var(--bi-radius); - box-shadow: - 0 2px 8px rgba(15, 23, 42, 0.04), - 0 1px 2px rgba(15, 23, 42, 0.02); - border: 1px solid rgba(37, 99, 235, 0.18); - padding: 18px 20px 20px; -} - -.ehb-feature__bar { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 16px; - padding-bottom: 14px; - border-bottom: 1px solid var(--bi-hairline); -} - -.ehb-feature__bar h2 { - margin: 0; - font-size: 18px; - font-weight: 700; - color: var(--bi-text); - letter-spacing: -0.02em; - display: flex; - align-items: center; - gap: 8px; -} - -.ehb-feature__bar h2::before { - content: ""; - display: inline-block; - width: 4px; - height: 16px; - border-radius: 999px; - background: var(--bi-blue); -} - -.ehb-dim-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 12px; - margin-bottom: 14px; -} - -.ehb-dim { - position: relative; - text-align: left; - border: 1px solid var(--bi-hairline); - background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); - border-radius: var(--bi-radius-sm); - padding: 14px 16px 12px; - cursor: pointer; - overflow: hidden; - transition: - border-color 0.15s ease, - box-shadow 0.15s ease, - transform 0.15s ease; -} - -.ehb-dim::before { - content: ""; - position: absolute; - inset: 0 0 auto; - height: 3px; - background: var(--bi-blue); -} - -.ehb-dim.is-lease::before { - background: linear-gradient(90deg, #2563eb, #60a5fa); -} -.ehb-dim.is-logistics::before { - background: linear-gradient(90deg, #0891b2, #22d3ee); -} -.ehb-dim.is-ops::before { - background: linear-gradient(90deg, #7c3aed, #a78bfa); -} - -.ehb-dim:hover { - border-color: rgba(37, 99, 235, 0.3); - transform: translateY(-1px); -} - -.ehb-dim.is-active { - background: var(--bi-blue-soft); - border-color: rgba(37, 99, 235, 0.45); - box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); -} - -@media (prefers-reduced-motion: reduce) { - .ehb-dim, - .ehb-rail__item, - .ehb-chip, - .ehb-seg button, - .ehb-btn { - transition: none; - } - .ehb-dim:hover { - transform: none; - } -} - -.ehb-dim__name { - font-size: 13px; - font-weight: 600; - color: var(--bi-muted); -} - -.ehb-dim__amt { - margin-top: 4px; - font-size: 22px; - font-weight: 800; - color: var(--bi-text); - font-family: var(--bi-font-mono); - font-variant-numeric: tabular-nums; - letter-spacing: -0.02em; -} - -.ehb-dim__subs { - margin-top: 10px; - display: grid; - gap: 5px; -} - -.ehb-dim__sub { - display: flex; - justify-content: space-between; - align-items: center; - font-size: 12px; - color: var(--bi-muted); - padding: 5px 8px; - border-radius: 6px; - background: rgba(255, 255, 255, 0.85); - border: 1px solid transparent; - cursor: pointer; - transition: - background 0.12s ease, - border-color 0.12s ease; -} - -.ehb-dim__sub:hover { - background: #ffffff; - border-color: rgba(148, 163, 184, 0.25); -} - -.ehb-dim__sub.is-active { - border-color: rgba(37, 99, 235, 0.35); - color: var(--bi-blue); - font-weight: 600; - background: #ffffff; -} - -.ehb-dim__sub strong { - color: var(--bi-text-sub); - font-weight: 600; - font-family: var(--bi-font-mono); - font-variant-numeric: tabular-nums; -} - -.ehb-pending { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px 12px; - padding: 8px 12px; - border-radius: 8px; - background: #fffbeb; - border: 1px solid rgba(217, 119, 6, 0.22); - font-size: 12px; - color: #92400e; - margin-bottom: 14px; -} - -.ehb-pending strong { - font-family: var(--bi-font-mono); - font-variant-numeric: tabular-nums; - font-weight: 600; -} - -.ehb-pending__sep { - width: 1px; - height: 12px; - background: rgba(217, 119, 6, 0.28); -} - -/* —— 面板与表格 —— */ -.ehb-panel { - margin-top: 14px; - border: 1px solid var(--bi-hairline); - border-radius: var(--bi-radius-sm); - background: #ffffff; - overflow: hidden; -} - -.ehb-panel__head { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 8px 12px; - padding: 8px 12px; - border-bottom: 1px solid var(--bi-hairline); - background: #f8fafc; -} - -.ehb-panel__meta { - font-size: 12px; - color: var(--bi-tertiary); -} - -.ehb-stats__tabs { - display: inline-flex; - gap: 3px; - background: #e2e8f0; - border-radius: 8px; - padding: 3px; -} - -.ehb-stats__tabs button { - border: none; - background: transparent; - height: 28px; - padding: 0 11px; - border-radius: 6px; - font-size: 12px; - font-weight: 500; - color: var(--bi-muted); - cursor: pointer; -} - -.ehb-stats__tabs button.is-active { - background: #ffffff; - color: var(--bi-blue); - font-weight: 600; - box-shadow: var(--bi-shadow-sm); -} - -.ehb-stats__path { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px; - font-size: 12px; - color: var(--bi-muted); -} - -.ehb-stats__path button { - border: none; - background: transparent; - color: var(--bi-blue); - font-weight: 600; - cursor: pointer; - padding: 0; -} - -.ehb-section-title { - margin: 0; - font-size: 13px; - font-weight: 600; - color: var(--bi-text-body); -} - -.ehb-table-wrap { - overflow: auto; - max-height: min(40vh, 400px); - background: #ffffff; -} - -.ehb-panel .ehb-table-wrap { - border: none; - border-radius: 0; -} - -.ehb-table { - width: 100%; - border-collapse: separate; - border-spacing: 0; - min-width: max(100%, 720px); - font-size: 13px; -} - -.ehb-table th { - position: sticky; - top: 0; - z-index: 1; - text-align: left; - font-size: 12px; - font-weight: 500; - color: var(--bi-muted); - background-color: #f8fafc; - background-clip: padding-box; - transform: translateZ(0); - padding: 8px 12px; - border-bottom: 1px solid var(--bi-hairline); - white-space: nowrap; -} - -.ehb-table td { - padding: 8px 12px; - border-bottom: 1px solid var(--bi-hairline); - color: var(--bi-text-body); - font-weight: 400; -} - -.ehb-table tr:last-child td { - border-bottom: none; -} - -.ehb-table tr.is-clickable { - cursor: pointer; -} - -.ehb-table tr.is-clickable:hover td { - background: #f1f5f9; -} - -.ehb-table tr.is-active td { - background: rgba(37, 99, 235, 0.08); -} - -.ehb-mono { - font-variant-numeric: tabular-nums; - font-family: var(--bi-font-mono); - font-size: 12px; - color: var(--bi-text-sub); -} - -.ehb-mono.ehb-idx { - color: var(--bi-tertiary); - font-weight: 400; -} - -.ehb-badge { - display: inline-flex; - align-items: center; - height: 20px; - padding: 0 7px; - border-radius: 999px; - font-size: 11px; - font-weight: 600; -} - -.ehb-badge.is-ok { - background: #ecfdf5; - color: #047857; -} - -.ehb-badge.is-warn { - background: #fffbeb; - color: #b45309; -} - -.ehb-empty { - padding: 36px 16px; - text-align: center; - color: var(--bi-muted); - font-size: 13px; -} - -.ehb-empty__icon { - color: var(--bi-blue); -} - -.ehb-empty__title { - margin-top: 8px; - font-weight: 600; - color: var(--bi-text-body); -} - -/* —— 宿主按日视图 (Daily View) 专用样式 —— */ - -.ehb-daily-filter-card { - background: #ffffff; - border-radius: var(--bi-radius); - border: 1px solid var(--bi-hairline); - padding: 12px 16px; - margin-bottom: 12px; - display: flex; - flex-direction: column; - gap: 12px; -} - -.ehb-daily-filter-row { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.ehb-daily-filter-group { - display: flex; - align-items: center; - gap: 8px; -} - -.ehb-pill-tabs { - display: flex; - background: #f1f5f9; - border-radius: 6px; - padding: 2px; - gap: 2px; -} - -.ehb-pill-btn { - border: none; - background: transparent; - padding: 4px 12px; - font-size: 12px; - font-weight: 500; - color: var(--bi-muted); - border-radius: 4px; - cursor: pointer; - transition: all 0.15s ease; -} - -.ehb-pill-btn:hover { - color: var(--bi-text-body); -} - -.ehb-pill-btn.is-active { - background: #ffffff; - color: var(--bi-blue); - font-weight: 600; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); -} - -.ehb-daily-date-picker-wrapper { - position: relative; - display: inline-block; -} - -.ehb-daily-date-picker { - display: flex; - align-items: center; - gap: 8px; - background: #ffffff; - border: 1px solid #cbd5e1; - border-radius: 6px; - padding: 4px 10px; - font-size: 12px; - color: var(--bi-text-body); - font-family: var(--bi-font-mono); - cursor: pointer; - user-select: none; - transition: all 0.15s ease; -} - -.ehb-daily-date-picker:hover, -.ehb-daily-date-picker.is-active { - border-color: #0284c7; - box-shadow: 0 0 0 2px rgba(2, 132, 199, 0.12); -} - -.ehb-date-label { - color: #64748b; - font-weight: 500; -} - -.ehb-date-val { - color: #0f172a; - font-weight: 600; -} - -/* 自定义非原生日历 Popover 下拉卡片 */ -.ehb-date-popover { - position: absolute; - top: calc(100% + 6px); - left: 0; - z-index: 1000; - width: 238px; - background: #ffffff; - border: 1px solid #cbd5e1; - border-radius: 8px; - padding: 10px; - box-shadow: - 0 10px 25px -5px rgba(0, 0, 0, 0.12), - 0 8px 10px -6px rgba(0, 0, 0, 0.08); - font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; -} - -/* 模式切换条: 按日 | 按月 | 按年 */ -.ehb-dp-mode-bar { - display: flex; - background: #f1f5f9; - border-radius: 6px; - padding: 2px; - gap: 2px; - margin-bottom: 8px; -} - -.ehb-dp-mode-btn { - flex: 1; - border: none; - background: transparent; - padding: 3px 0; - font-size: 11px; - font-weight: 500; - color: #64748b; - border-radius: 4px; - cursor: pointer; - transition: all 0.12s ease; - text-align: center; -} - -.ehb-dp-mode-btn.is-active { - background: #ffffff; - color: #0284c7; - font-weight: 600; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); -} - -.ehb-dp-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 8px; -} - -.ehb-dp-title-group { - display: flex; - align-items: center; - gap: 4px; -} - -.ehb-dp-title-btn { - border: none; - background: transparent; - padding: 2px 6px; - border-radius: 4px; - font-size: 13px; - font-weight: 700; - color: #0f172a; - cursor: pointer; - transition: all 0.12s ease; -} - -.ehb-dp-title-btn:hover { - background: #f1f5f9; - color: #0284c7; -} - -.ehb-dp-title-btn.is-active { - color: #0284c7; - background: #e0f2fe; -} - -.ehb-dp-title { - font-size: 13px; - font-weight: 700; - color: #0f172a; -} - -.ehb-dp-nav-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - border: none; - background: #f1f5f9; - border-radius: 4px; - color: #475569; - cursor: pointer; - transition: all 0.15s ease; -} - -.ehb-dp-nav-btn:hover { - background: #e2e8f0; - color: #0284c7; -} - -.ehb-dp-week-row { - display: grid; - grid-template-columns: repeat(7, 1fr); - text-align: center; - font-size: 11px; - font-weight: 600; - color: #94a3b8; - margin-bottom: 6px; -} - -.ehb-dp-grid { - display: grid; - grid-template-columns: repeat(7, 1fr); - gap: 2px; -} - -.ehb-dp-day { - display: flex; - align-items: center; - justify-content: center; - height: 26px; - border: none; - background: transparent; - border-radius: 4px; - font-size: 12px; - font-family: var(--bi-font-mono); - color: #334155; - cursor: pointer; - transition: all 0.12s ease; -} - -.ehb-dp-day:hover:not(.is-selected):not(.is-empty) { - background: #f1f5f9; - color: #0284c7; -} - -.ehb-dp-day.is-selected { - background: #0284c7; - color: #ffffff; - font-weight: 700; -} - -.ehb-dp-day.is-empty { - cursor: default; -} - -/* 月选择网格 */ -.ehb-dp-month-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 6px; - padding: 4px 0; -} - -.ehb-dp-month-item { - height: 34px; - border: 1px solid #e2e8f0; - background: #ffffff; - border-radius: 6px; - font-size: 12px; - font-weight: 500; - color: #334155; - cursor: pointer; - transition: all 0.12s ease; -} - -.ehb-dp-month-item:hover:not(.is-selected) { - border-color: #38bdf8; - color: #0284c7; - background: #f0f9ff; -} - -.ehb-dp-month-item.is-selected { - background: #0284c7; - border-color: #0284c7; - color: #ffffff; - font-weight: 700; -} - -/* 年选择网格 */ -.ehb-dp-year-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 6px; - padding: 4px 0; -} - -.ehb-dp-year-item { - height: 34px; - border: 1px solid #e2e8f0; - background: #ffffff; - border-radius: 6px; - font-size: 12px; - font-family: var(--bi-font-mono); - font-weight: 500; - color: #334155; - cursor: pointer; - transition: all 0.12s ease; -} - -.ehb-dp-year-item:hover:not(.is-selected) { - border-color: #38bdf8; - color: #0284c7; - background: #f0f9ff; -} - -.ehb-dp-year-item.is-selected { - background: #0284c7; - border-color: #0284c7; - color: #ffffff; - font-weight: 700; -} - -.ehb-fleet-segmented { - display: flex; - background: #f8fafc; - border: 1px solid #e2e8f0; - border-radius: 6px; - padding: 2px; - gap: 4px; -} - -.ehb-fleet-btn { - display: flex; - align-items: center; - gap: 6px; - border: none; - background: transparent; - padding: 5px 14px; - font-size: 12px; - font-weight: 500; - color: var(--bi-muted); - border-radius: 4px; - cursor: pointer; - transition: all 0.15s ease; -} - -.ehb-fleet-btn.is-active { - background: #ffffff; - color: var(--bi-blue); - font-weight: 600; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); -} - -.ehb-daily-kpi-grid { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 12px; - margin-bottom: 12px; -} - -.ehb-daily-kpi-card { - background: #ffffff; - border-radius: var(--bi-radius-sm); - border: 1px solid var(--bi-hairline); - padding: 12px 16px; - display: flex; - flex-direction: column; - position: relative; -} - -.ehb-daily-kpi-head { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 6px; -} - -.ehb-daily-kpi-title { - font-size: 12px; - font-weight: 500; - color: var(--bi-muted); -} - -.ehb-daily-kpi-val { - font-size: 24px; - font-weight: 800; - color: var(--bi-text-body); - font-family: var(--bi-font-mono); - font-variant-numeric: tabular-nums; - line-height: 1.2; - margin-bottom: 4px; - display: flex; - align-items: baseline; - gap: 3px; -} - -.ehb-daily-kpi-sub { - font-size: 11px; - color: var(--bi-tertiary); - font-family: var(--bi-font-mono); -} - -.ehb-daily-chart-section { - background: #ffffff; - border-radius: var(--bi-radius); - border: 1px solid var(--bi-hairline); - padding: 16px; - margin-bottom: 12px; -} - -.ehb-daily-chart-head { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 12px; -} - -.ehb-daily-chart-title { - font-size: 14px; - font-weight: 700; - color: var(--bi-text-body); -} - -.ehb-daily-chart-meta-group { - display: flex; - align-items: center; - gap: 16px; -} - -.ehb-daily-chart-legend { - display: flex; - align-items: center; - gap: 12px; -} - -.ehb-legend-item { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 12px; - font-weight: 500; - color: #475569; -} - -.ehb-legend-dot { - width: 8px; - height: 8px; - border-radius: 2px; -} - -.ehb-legend-dot.is-own { - background: #0284c7; -} - -.ehb-legend-dot.is-ext { - background: #f59e0b; -} - -.ehb-daily-chart-meta { - font-size: 11px; - color: var(--bi-tertiary); -} - -.ehb-daily-summary-pills { - display: flex; - gap: 16px; - margin-bottom: 16px; - background: #f8fafc; - padding: 8px 12px; - border-radius: 6px; -} - -.ehb-daily-pill-item { - display: flex; - align-items: center; - gap: 6px; - font-size: 11px; - color: var(--bi-muted); -} - -.ehb-daily-pill-item strong { - color: var(--bi-text-body); - font-weight: 700; - font-family: var(--bi-font-mono); -} - -.ehb-daily-bar-container { - height: 200px; - display: flex; - align-items: flex-end; - gap: 8px; - padding-top: 24px; - padding-bottom: 24px; - position: relative; - border-bottom: 1px solid #e2e8f0; - overflow-x: auto; - overflow-y: hidden; - scrollbar-width: thin; -} - -.ehb-daily-avg-line { - position: absolute; - left: 0; - right: 0; - min-width: 100%; - border-top: 1.5px dashed #2563eb; - opacity: 0.85; - pointer-events: none; - z-index: 5; -} - -.ehb-daily-avg-label { - position: sticky; - left: 8px; - top: -11px; - font-size: 11px; - font-weight: 600; - color: #1e40af; - background: #eff6ff; - border: 1px solid #93c5fd; - padding: 1px 8px; - border-radius: 4px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); - white-space: nowrap; -} - -.ehb-daily-bar-col { - flex: 1; - min-width: 18px; - display: flex; - flex-direction: column; - align-items: center; - height: 100%; - justify-content: flex-end; - position: relative; - cursor: pointer; -} - -.ehb-daily-bar-fill { - width: 100%; - max-width: 28px; - background: linear-gradient(180deg, #38bdf8 0%, #0284c7 100%); - border-radius: 4px 4px 0 0; - transition: all 0.2s ease; - position: relative; -} - -.ehb-daily-bar-fill.is-stacked { - display: flex; - flex-direction: column; - overflow: hidden; - background: transparent; -} - -.ehb-daily-bar-fill.is-stacked.is-active { - box-shadow: 0 0 10px rgba(2, 132, 199, 0.5); -} - -.ehb-bar-segment { - width: 100%; - transition: all 0.2s ease; -} - -.ehb-bar-segment.is-ext { - background: linear-gradient(180deg, #fbbf24 0%, #f59e0b 100%); - border-bottom: 1px solid rgba(255, 255, 255, 0.5); -} - -.ehb-bar-segment.is-own { - background: linear-gradient(180deg, #38bdf8 0%, #0284c7 100%); -} - -.ehb-daily-bar-col:hover .ehb-bar-segment.is-ext { - filter: brightness(1.1); -} - -.ehb-daily-bar-col:hover .ehb-bar-segment.is-own { - filter: brightness(1.1); -} - -.ehb-daily-bar-val { - position: absolute; - top: -20px; - font-size: 10px; - color: var(--bi-muted); - font-family: var(--bi-font-mono); - white-space: nowrap; -} - -.ehb-daily-bar-label { - margin-top: 8px; - font-size: 10px; - color: var(--bi-tertiary); - font-family: var(--bi-font-mono); -} - -.ehb-daily-table-card { - background: #ffffff; - border-radius: var(--bi-radius); - border: 1px solid var(--bi-hairline); - padding: 16px; -} - -.ehb-daily-table-head { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 12px; -} - -.ehb-daily-table-title { - font-size: 14px; - font-weight: 700; - color: var(--bi-text-body); -} - -/* 按日明细:日期是一级范围,站点是二级维度,避免两个展开控件混淆。 */ -.ehb-daily-date-row { - scroll-margin-top: 84px; -} - -.ehb-daily-date-row.is-highlighted td { - background: #dff3ff !important; - box-shadow: inset 3px 0 0 #0284c7; - transition: background-color 0.2s ease; -} - -.ehb-daily-level-tag { - display: inline-flex; - align-items: center; - gap: 3px; - margin-right: 6px; - padding: 2px 5px; - border-radius: 4px; - font-size: 10px; - font-weight: 700; - line-height: 1.2; - vertical-align: middle; -} - -.ehb-daily-level-tag.is-date { - color: #0369a1; - background: #e0f2fe; -} - -.ehb-daily-level-tag.is-station { - color: #047857; - background: #d1fae5; -} - -.ehb-daily-disclosure { - display: inline-flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - margin-right: 6px; - border-radius: 4px; - font-size: 10px; - font-weight: 800; - vertical-align: middle; -} - -.ehb-daily-disclosure.is-date { - color: #0369a1; - background: #eff6ff; -} - -.ehb-daily-disclosure.is-station { - color: #047857; - background: #ecfdf5; - font-size: 14px; -} - -.ehb-title-sub { - font-size: 12px; - font-weight: 400; - color: var(--bi-tertiary); - margin-left: 4px; -} - -.ehb-show-h5 { - display: none !important; -} - -.ehb-hide-h5 { - display: inline !important; -} - -.ehb-export-btn { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 12px; - padding: 5px 12px; - border-radius: 6px; - cursor: pointer; - transition: all 0.15s ease; -} - -/* —— 钻取 Badge 标签 —— */ -.ehb-tag { - display: inline-flex; - align-items: center; - gap: 3px; - padding: 1px 6px; - border-radius: 4px; - font-size: 11px; - font-weight: 600; - line-height: 1.4; - cursor: help; -} - -.ehb-tag--self-use { - background: #eff6ff; - color: #2563eb; - border: 1px solid #bfdbfe; -} - -.ehb-tag--ext-sale { - background: #f0fdf4; - color: #16a34a; - border: 1px solid #bbf7d0; -} - -/* 单站拆分内部/外部车辆加氢显示标签 */ -.ehb-station-cell { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 6px; /* 加氢站名称与标签组垂直间距,舒适有呼吸感 */ - padding: 4px 0; -} - -.ehb-station-title-row { - display: inline-flex; - align-items: center; - font-weight: 600; - color: #0f172a; - line-height: 1.4; -} - -.ehb-arrow-icon { - margin-right: 6px; - color: #0284c7; - display: inline-block; - width: 14px; -} - -.ehb-split-tag-group { - display: flex; - align-items: center; - gap: 6px 10px; /* 水平 10px,换行时垂直 6px */ - flex-wrap: wrap; -} - -.ehb-split-tag { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 11px; - padding: 3px 10px; - border-radius: 4px; - border: 1px solid transparent; - line-height: 1.4; - white-space: nowrap; -} - -.ehb-split-tag.is-own { - background: #f0f9ff; - border-color: #bae6fd; - color: #0369a1; -} - -.ehb-split-tag.is-ext { - background: #f8fafc; - border-color: #cbd5e1; - color: #475569; -} - -.ehb-split-tag__label { - font-weight: 600; - padding-right: 6px; - border-right: 1px solid rgba(0, 0, 0, 0.1); -} - -.ehb-split-tag__val { - font-family: var(--bi-font-mono); - font-weight: 700; -} - -.ehb-split-tag__price { - font-family: var(--bi-font-mono); - opacity: 0.88; -} - -.ehb-tag--own-fleet { - background: #f0fdf4; - color: #15803d; - border: 1px solid #bbf7d0; -} - -.ehb-tag--ext-fleet { - background: #f1f5f9; - color: #64748b; - border: 1px solid #e2e8f0; -} - -.ehb-tag--ext-cust { - background: #fff7ed; - color: #c2410c; - border: 1px solid #ffedd5; -} - -.ehb-tag--source-api { - background: #e0f2fe; - color: #0369a1; -} - -.ehb-tag--source-station { - background: #fff7ed; - color: #c2410c; -} - -.ehb-tag--source-lingniu { - background: #faf5ff; - color: #7e22ce; -} - -.ehb-tag--verify-ok { - background: #ecfdf5; - color: #047857; - border: 1px solid #a7f3d0; -} - -.ehb-tag--verify-partial { - background: #fff7ed; - color: #c2410c; - border: 1px solid #ffedd5; -} - -.ehb-tag--verify-warn { - background: #fffbeb; - color: #b45309; - border: 1px solid #fde68a; -} - -/* 锚点闪烁高亮 */ -.is-highlight-target { - animation: ehbHighlightPulse 2s ease-out; -} - -@keyframes ehbHighlightPulse { - 0% { - background-color: rgba(59, 130, 246, 0.25); - box-shadow: inset 0 0 0 2px #3b82f6; - } - 100% { - background-color: transparent; - box-shadow: none; - } -} - -/* 总览视角:趋势图表大盘组件样式 */ -.ehb-overview-charts { - display: flex; - flex-direction: column; - gap: 12px; - margin-top: 12px; - margin-bottom: 12px; -} - -.ehb-chart-box { - background: #ffffff; - border-radius: var(--bi-radius); - border: 1px solid var(--bi-hairline); - padding: 16px; -} - -.ehb-chart-box-head { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 16px; -} - -.ehb-chart-box-title { - font-size: 14px; - font-weight: 700; - color: var(--bi-text-body); -} - -.ehb-chart-box-meta { - font-size: 11px; - color: var(--bi-tertiary); -} - -/* Recharts 容器必须有显式高度,否则 `height="100%"` 会坍缩为 0。 */ -.ehb-chart-box-body { - height: 220px; - min-height: 220px; -} - -.ehb-chart-legend-inline { - display: flex; - align-items: center; - gap: 14px; -} - -.ehb-chart-legend-tag { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 12px; - font-weight: 500; - color: #475569; -} - -.ehb-legend-sq { - width: 10px; - height: 10px; - border-radius: 2px; -} - -.ehb-legend-sq.is-income { - background: #10b981; -} - -.ehb-legend-sq.is-cost { - background: #f59e0b; -} - -.ehb-legend-sq.is-cost-customer { - background: #f59e0b; -} - -.ehb-legend-sq.is-cost-company { - background: #2563eb; -} - -.ehb-legend-sq.is-cost-other { - background: #94a3b8; -} - -/* 月度加氢量柱状图 */ -.ehb-mbar-chart { - height: 160px; - display: flex; - align-items: flex-end; - gap: 12px; - padding-top: 20px; - padding-bottom: 20px; - border-bottom: 1px solid #f1f5f9; -} - -.ehb-mbar-col { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - height: 100%; - justify-content: flex-end; - position: relative; - cursor: pointer; -} - -.ehb-mbar-val { - position: absolute; - top: -18px; - font-size: 11px; - font-weight: 600; - font-family: var(--bi-font-mono); - color: #475569; - white-space: nowrap; -} - -.ehb-mbar-fill { - width: 100%; - max-width: 42px; - background: linear-gradient(180deg, #38bdf8 0%, #0284c7 100%); - border-radius: 4px 4px 0 0; - transition: all 0.2s ease; -} - -.ehb-mbar-col:hover .ehb-mbar-fill { - filter: brightness(1.1); - transform: scaleY(1.02); -} - -/* 柱状图 Hover 自定义浮动卡片 */ -.ehb-mbar-tooltip { - position: absolute; - bottom: calc(100% + 8px); - left: 50%; - transform: translateX(-50%) translateY(4px); - background: rgba(15, 23, 42, 0.92); - backdrop-filter: blur(8px); - color: #ffffff; - padding: 8px 12px; - border-radius: 6px; - box-shadow: - 0 10px 25px -5px rgba(0, 0, 0, 0.25), - 0 8px 10px -6px rgba(0, 0, 0, 0.2); - font-size: 11px; - white-space: nowrap; - pointer-events: none; - opacity: 0; - visibility: hidden; - transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); - z-index: 20; -} - -.ehb-mbar-col:hover .ehb-mbar-tooltip { - opacity: 1; - visibility: visible; - transform: translateX(-50%) translateY(0); -} - -.ehb-mbar-tooltip__head { - font-weight: 700; - font-size: 11px; - margin-bottom: 4px; - padding-bottom: 4px; - border-bottom: 1px solid rgba(255, 255, 255, 0.15); - color: #f8fafc; -} - -.ehb-mbar-tooltip__row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - line-height: 1.6; -} - -.ehb-mbar-tooltip__left { - display: flex; - align-items: center; - gap: 6px; - color: #cbd5e1; -} - -.ehb-mbar-tooltip__dot { - width: 6px; - height: 6px; - border-radius: 50%; -} - -.ehb-mbar-tooltip__dot.is-own { - background: #38bdf8; -} - -.ehb-mbar-tooltip__dot.is-ext { - background: #f59e0b; -} - -.ehb-mbar-tooltip__val { - font-weight: 700; - font-family: var(--bi-font-mono); - color: #ffffff; -} - -.ehb-mbar-label { - margin-top: 8px; - font-size: 11px; - color: #64748b; - font-family: var(--bi-font-mono); -} - -/* 月度收支对比图 */ -.ehb-rev-chart { - height: 160px; - display: flex; - align-items: flex-end; - gap: 16px; - padding-top: 20px; - padding-bottom: 20px; - border-bottom: 1px solid #f1f5f9; -} - -.ehb-rev-col-group { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - height: 100%; - justify-content: flex-end; -} - -.ehb-rev-bars { - display: flex; - align-items: flex-end; - gap: 4px; - height: 100%; - width: 100%; - justify-content: center; -} - -.ehb-rev-bar { - width: 16px; - border-radius: 3px 3px 0 0; - transition: all 0.2s ease; - position: relative; - cursor: pointer; -} - -/* 月度成本由同一根堆叠柱展示:对客、我司承担、其他。每段可单独钻取。 */ -.ehb-rev-cost-stack { - width: 16px; - min-height: 0; - display: flex; - flex-direction: column-reverse; - overflow: visible; - border-radius: 3px 3px 0 0; - position: relative; -} - -.ehb-rev-cost-segment { - display: block; - width: 100%; - min-height: 2px; - border: 0; - padding: 0; - cursor: pointer; - transition: filter 0.2s ease; -} - -.ehb-rev-cost-segment:hover { - filter: brightness(1.08); -} - -.ehb-rev-cost-segment:first-child { - border-radius: 0 0 0 0; -} - -.ehb-rev-cost-segment:last-of-type { - border-radius: 3px 3px 0 0; -} - -.ehb-rev-cost-segment.is-customer { - background: linear-gradient(180deg, #fbbf24 0%, #f59e0b 100%); -} - -.ehb-rev-cost-segment.is-company { - background: linear-gradient(180deg, #60a5fa 0%, #2563eb 100%); -} - -.ehb-rev-cost-segment.is-other { - background: linear-gradient(180deg, #cbd5e1 0%, #94a3b8 100%); -} - -/* 客户收入 Hover 浮层,高保真显示 TOP9 客户 + 其他客户 */ -.ehb-rev-income-tooltip { - position: absolute; - bottom: calc(100% + 8px); - left: 50%; - transform: translateX(-50%) translateY(4px); - background: rgba(15, 23, 42, 0.94); - backdrop-filter: blur(10px); - color: #ffffff; - padding: 10px 14px; - border-radius: 8px; - box-shadow: - 0 12px 30px -5px rgba(0, 0, 0, 0.35), - 0 8px 12px -6px rgba(0, 0, 0, 0.25); - font-size: 11px; - white-space: nowrap; - pointer-events: none; - opacity: 0; - visibility: hidden; - transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); - z-index: 30; - min-width: 270px; -} - -/* 成本支出 Hover 浮层,高保真显示包氢、物流、运维异动等成本项目 */ -.ehb-rev-cost-tooltip { - position: absolute; - bottom: calc(100% + 8px); - left: 50%; - transform: translateX(-50%) translateY(4px); - background: rgba(15, 23, 42, 0.94); - backdrop-filter: blur(10px); - color: #ffffff; - padding: 10px 14px; - border-radius: 8px; - box-shadow: - 0 12px 30px -5px rgba(0, 0, 0, 0.35), - 0 8px 12px -6px rgba(0, 0, 0, 0.25); - font-size: 11px; - white-space: nowrap; - pointer-events: none; - opacity: 0; - visibility: hidden; - transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); - z-index: 30; - min-width: 220px; -} - -/* 避免最边缘的 1月 或 8月 弹窗被裁剪,靠近左侧靠左对齐,靠近右侧靠右对齐 */ -.ehb-rev-col-group:first-child .ehb-rev-income-tooltip, -.ehb-rev-col-group:first-child .ehb-rev-cost-tooltip { - left: 0; - transform: translateX(0) translateY(4px); -} -.ehb-rev-col-group:first-child - .ehb-rev-bar.is-income:hover - .ehb-rev-income-tooltip, -.ehb-rev-col-group:first-child - .ehb-rev-bar.is-cost:hover - .ehb-rev-cost-tooltip { - transform: translateX(0) translateY(0); -} - -.ehb-rev-col-group:first-child - .ehb-rev-cost-stack:hover - .ehb-rev-cost-tooltip { - transform: translateX(0) translateY(0); -} - -.ehb-rev-col-group:last-child .ehb-rev-income-tooltip, -.ehb-rev-col-group:last-child .ehb-rev-cost-tooltip { - left: auto; - right: 0; - transform: translateX(0) translateY(4px); -} -.ehb-rev-col-group:last-child - .ehb-rev-bar.is-income:hover - .ehb-rev-income-tooltip, -.ehb-rev-col-group:last-child .ehb-rev-bar.is-cost:hover .ehb-rev-cost-tooltip { - transform: translateX(0) translateY(0); -} - -.ehb-rev-col-group:last-child - .ehb-rev-cost-stack:hover - .ehb-rev-cost-tooltip { - transform: translateX(0) translateY(0); -} - -.ehb-rev-bar.is-income:hover .ehb-rev-income-tooltip, -.ehb-rev-bar.is-cost:hover .ehb-rev-cost-tooltip { - opacity: 1; - visibility: visible; - transform: translateX(-50%) translateY(0); -} - -.ehb-rev-cost-stack:hover .ehb-rev-cost-tooltip { - opacity: 1; - visibility: visible; - transform: translateX(-50%) translateY(0); -} - -.ehb-rev-income-tooltip__head { - display: flex; - align-items: center; - justify-content: space-between; - font-weight: 700; - font-size: 11px; - margin-bottom: 6px; - padding-bottom: 5px; - border-bottom: 1px solid rgba(255, 255, 255, 0.15); - color: #34d399; -} - -.ehb-rev-cost-tooltip__head { - display: flex; - align-items: center; - justify-content: space-between; - font-weight: 700; - font-size: 11px; - margin-bottom: 6px; - padding-bottom: 5px; - border-bottom: 1px solid rgba(255, 255, 255, 0.15); - color: #fbbf24; -} - -.ehb-rev-cost-tooltip__list { - display: flex; - flex-direction: column; - gap: 4px; -} - -.ehb-rev-cost-tooltip__item { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - line-height: 1.5; -} - -.ehb-rev-cost-tooltip__tag { - display: inline-flex; - align-items: center; - gap: 6px; - color: #cbd5e1; - font-weight: 500; -} - -.ehb-rev-cost-tooltip__dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: #f59e0b; -} - -.ehb-rev-cost-tooltip__item.is-company .ehb-rev-cost-tooltip__dot { - background: #60a5fa; -} - -.ehb-rev-cost-tooltip__item.is-other .ehb-rev-cost-tooltip__dot { - background: #94a3b8; -} - -.ehb-rev-cost-tooltip__val { - font-weight: 700; - font-family: var(--bi-font-mono); - color: #ffffff; -} - -.ehb-rev-cost-tooltip__foot { - margin-top: 6px; - padding-top: 5px; - border-top: 1px dashed rgba(255, 255, 255, 0.18); - display: flex; - align-items: center; - justify-content: space-between; - font-weight: 700; -} - -.ehb-rev-income-tooltip__list { - display: flex; - flex-direction: column; - gap: 4px; -} - -.ehb-rev-income-tooltip__item { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - line-height: 1.5; -} - -.ehb-rev-income-tooltip__cust-name { - color: #cbd5e1; - font-weight: 500; - max-width: 175px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.ehb-rev-income-tooltip__cust-name.is-other { - color: #94a3b8; - font-style: italic; -} - -.ehb-rev-income-tooltip__cust-val { - font-weight: 700; - font-family: var(--bi-font-mono); - color: #ffffff; -} - -.ehb-rev-income-tooltip__foot { - margin-top: 6px; - padding-top: 5px; - border-top: 1px dashed rgba(255, 255, 255, 0.18); - display: flex; - align-items: center; - justify-content: space-between; - font-weight: 700; -} - -.ehb-rev-bar.is-cost { - background: linear-gradient(180deg, #fbbf24 0%, #f59e0b 100%); -} - -.ehb-rev-bar.is-income { - background: linear-gradient(180deg, #34d399 0%, #10b981 100%); -} - -.ehb-rev-label { - margin-top: 8px; - font-size: 11px; - color: #64748b; - font-family: var(--bi-font-mono); -} - -/* 两图排布行 */ -.ehb-two-charts-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; -} - -/* 汇总大表卡片 (加氢站加氢汇总 & 客户账单汇总) */ -.ehb-sum-table-card { - background: #ffffff; - border-radius: var(--bi-radius); - border: 1px solid var(--bi-hairline); - padding: 18px; - margin-top: 12px; -} - -.ehb-sum-table-card__head { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 12px; -} - -.ehb-sum-table-card__title { - font-size: 14px; - font-weight: 700; - color: var(--bi-text-body); -} - -.ehb-sum-table-card__meta { - font-size: 11px; - font-weight: 600; - color: #64748b; - font-family: var(--bi-font-mono); -} - -.ehb-sum-table-wrap { - width: 100%; - overflow-x: auto; -} - -.ehb-sum-table { - width: 100%; - border-collapse: collapse; - text-align: left; -} - -.ehb-sum-table th { - font-size: 11px; - font-weight: 600; - color: #64748b; - padding: 10px 12px; - border-bottom: 1px solid #f1f5f9; - white-space: nowrap; -} - -.ehb-sum-table td { - font-size: 12px; - color: #334155; - padding: 10px 12px; - border-bottom: 1px solid #f8fafc; - white-space: nowrap; -} - -.ehb-sum-table tr:hover td { - background-color: #f8fafc; -} - -.ehb-sum-table .col-idx { - width: 40px; - text-align: center; - color: #94a3b8; - font-family: var(--bi-font-mono); - font-size: 11px; -} - -.ehb-sum-table .col-bold-kg { - font-weight: 700; - font-family: var(--bi-font-mono); - color: #0f172a; -} - -.ehb-sum-table .col-green-fee { - font-weight: 700; - color: #10b981; - font-family: var(--bi-font-mono); -} - -.ehb-sum-table .col-orange-cost { - font-weight: 700; - color: #f59e0b; - font-family: var(--bi-font-mono); -} - -.ehb-stay-tuned-tag { - display: inline-block; - font-size: 11px; - font-weight: 500; - color: #64748b; - background: #f1f5f9; - border: 1px dashed #cbd5e1; - border-radius: 4px; - padding: 1px 6px; - cursor: help; - transition: all 0.2s ease; -} - -.ehb-stay-tuned-tag:hover { - color: #533afd; - background: #f0f0ff; - border-color: #a5b4fc; -} - -/* KPI 数据来源穿透 Modal 弹窗 */ -.ehb-modal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(15, 23, 42, 0.7); - backdrop-filter: blur(8px); - z-index: 1000; - display: flex; - align-items: center; - justify-content: center; - padding: 20px; - animation: ehbFadeIn 0.2s cubic-bezier(0.16, 1, 0.3, 1); -} - -@keyframes ehbFadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -.ehb-modal-card { - background: #ffffff; - border-radius: 12px; - box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.35); - width: 100%; - max-width: 1100px; - max-height: 90vh; - display: flex; - flex-direction: column; - overflow: hidden; - border: 1px solid rgba(226, 232, 240, 0.8); - animation: ehbSlideUp 0.25s cubic-bezier(0.16, 1, 0.3, 1); -} - -@keyframes ehbSlideUp { - from { - opacity: 0; - transform: translateY(16px) scale(0.98); - } - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -.ehb-modal-head { - padding: 16px 20px; - background: #0f172a; - color: #ffffff; - display: flex; - align-items: center; - justify-content: space-between; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); -} - -.ehb-modal-head__title-group { - display: flex; - align-items: center; - gap: 10px; -} - -.ehb-modal-head__title { - font-size: 16px; - font-weight: 700; - color: #f8fafc; - display: flex; - align-items: center; - gap: 8px; -} - -.ehb-modal-head__sub { - font-size: 12px; - color: #94a3b8; - margin-top: 2px; -} - -.ehb-modal-back-btn { - display: inline-flex; - align-items: center; - gap: 4px; - background: rgba(255, 255, 255, 0.12); - border: 1px solid rgba(255, 255, 255, 0.2); - color: #f8fafc; - padding: 5px 10px; - border-radius: 8px; - font-size: 13px; - font-weight: 600; - cursor: pointer; - margin-right: 8px; - transition: all 0.2s ease; - flex-shrink: 0; -} - -.ehb-modal-back-btn:hover { - background: rgba(56, 189, 248, 0.2); - border-color: #38bdf8; - color: #38bdf8; -} - -.ehb-modal-head__actions { - display: flex; - align-items: center; - gap: 12px; -} - -.ehb-modal-close-btn { - background: rgba(255, 255, 255, 0.1); - border: none; - color: #cbd5e1; - width: 32px; - height: 32px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all 0.2s; -} - -.ehb-modal-close-btn:hover { - background: rgba(239, 68, 68, 0.8); - color: #ffffff; -} - -.ehb-modal-body { - padding: 20px; - overflow-y: auto; - flex: 1; - background: #f8fafc; -} - -.ehb-modal-meta-bar { - background: #ffffff; - border-radius: 8px; - border: 1px solid #e2e8f0; - padding: 14px 18px; - margin-bottom: 16px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - flex-wrap: wrap; -} - -.ehb-modal-meta-item { - display: flex; - flex-direction: column; - gap: 2px; -} - -.ehb-modal-meta-label { - font-size: 11px; - color: #64748b; -} - -.ehb-modal-meta-val { - font-size: 16px; - font-weight: 800; - color: #0f172a; - font-family: var(--bi-font-mono); -} - -.ehb-modal-filter-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 16px; - background: #ffffff; - padding: 10px 14px; - border-radius: 8px; - border: 1px solid #e2e8f0; - flex-wrap: wrap; -} - -.ehb-modal-filter-group { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; -} - -/* 穿透筛弱提示:灰色小字 */ -.ehb-modal-hint-text { - font-size: 11px; - font-weight: 400; - color: var(--bi-tertiary, #94a3b8); - line-height: 1.4; - margin: 0; -} - -.ehb-modal-filter-group > .ehb-modal-hint-text { - flex: 1; - min-width: 180px; -} - -.ehb-modal-hint-text strong { - color: inherit; - font-weight: 400; -} - -.ehb-order-more-row { - display: inline-flex; - align-items: center; - flex-wrap: wrap; - gap: 8px; -} - -.ehb-order-more-btn { - color: #0284c7; - cursor: pointer; - border: 1px solid #bae6fd; - background: #f0f9ff; - padding: 2px 8px; - border-radius: 4px; - font-size: 11px; - font-weight: 600; - font-family: inherit; - flex-shrink: 0; -} - -.ehb-order-more-btn:hover { - background: #e0f2fe; - border-color: #7dd3fc; -} - -.ehb-order-more-hint { - font-size: 11px; - font-weight: 400; - color: var(--bi-tertiary, #94a3b8); - line-height: 1.4; -} - -.ehb-modal-search-input { - display: inline-flex; - align-items: center; - gap: 6px; - background: #ffffff; - border: 1px solid #cbd5e1; - border-radius: 6px; - padding: 4px 10px; - height: 32px; - font-size: 12px; - width: 220px; - transition: all 0.2s; - box-sizing: border-box; -} - -.ehb-modal-search-input:focus-within { - border-color: #0284c7; - box-shadow: 0 0 0 2px rgba(2, 132, 199, 0.15); -} - -.ehb-modal-search-input input { - border: none !important; - outline: none !important; - background: transparent !important; - flex: 1; - min-width: 0; - padding: 0 !important; - margin: 0 !important; - font-size: 12px; - color: #0f172a; - font-family: inherit; - box-shadow: none !important; -} - -.ehb-modal-search-input button { - border: none; - background: transparent; - padding: 0; - margin: 0; - color: #94a3b8; - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; -} - -.ehb-modal-search-input button:hover { - color: #ef4444; -} - -.ehb-modal-select { - border: 1px solid #cbd5e1; - border-radius: 6px; - padding: 0 8px; - height: 32px; - font-size: 12px; - color: #334155; - background: #ffffff; - min-width: 140px; - outline: none; - cursor: pointer; - transition: all 0.2s; - box-sizing: border-box; -} - -/* BI 可搜索选择器(穿透筛 · 非 V2) */ -.ehb-bi-search-select { - position: relative; - flex-shrink: 0; -} - -.ehb-bi-search-select.is-disabled { - opacity: 0.55; - pointer-events: none; -} - -.ehb-bi-search-select__trigger { - display: inline-flex; - align-items: center; - justify-content: space-between; - gap: 6px; - width: 100%; - height: 32px; - padding: 0 10px; - border: 1px solid #cbd5e1; - border-radius: 6px; - background: #fff; - font-size: 12px; - color: #64748b; - cursor: pointer; - box-sizing: border-box; -} - -.ehb-bi-search-select__trigger.has-value { - color: #0f172a; -} - -.ehb-bi-search-select__trigger.is-open, -.ehb-bi-search-select__trigger:hover { - border-color: #0284c7; -} - -.ehb-bi-search-select__trigger.is-open { - box-shadow: 0 0 0 2px rgba(2, 132, 199, 0.15); -} - -.ehb-bi-search-select__label { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - text-align: left; - flex: 1; - min-width: 0; -} - -.ehb-bi-search-select__chevron { - flex-shrink: 0; - color: #64748b; -} - -.ehb-bi-search-select__dropdown { - position: absolute; - top: calc(100% + 4px); - left: 0; - right: 0; - min-width: 100%; - z-index: 40; - background: #fff; - border: 1px solid #e2e8f0; - border-radius: 8px; - box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12); - overflow: hidden; -} - -.ehb-bi-search-select__search { - display: flex; - align-items: center; - gap: 6px; - padding: 8px 10px; - border-bottom: 1px solid #e2e8f0; - color: #94a3b8; -} - -.ehb-bi-search-select__search input { - flex: 1; - min-width: 0; - border: none !important; - outline: none !important; - background: transparent !important; - box-shadow: none !important; - font-size: 12px; - color: #0f172a; - padding: 0 !important; - margin: 0 !important; - font-family: inherit; -} - -.ehb-bi-search-select__list { - max-height: 220px; - overflow-y: auto; - padding: 4px; -} - -.ehb-bi-search-select__item { - display: block; - width: 100%; - text-align: left; - border: none; - background: transparent; - padding: 8px 10px; - border-radius: 6px; - font-size: 12px; - color: #334155; - cursor: pointer; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.ehb-bi-search-select__item:hover { - background: #f1f5f9; -} - -.ehb-bi-search-select__item.is-selected { - background: #e0f2fe; - color: #0369a1; - font-weight: 600; -} - -.ehb-bi-search-select__empty { - padding: 12px 10px; - font-size: 12px; - color: #94a3b8; - text-align: center; -} - -.ehb-modal-select:focus { - border-color: #0284c7; - box-shadow: 0 0 0 2px rgba(2, 132, 199, 0.15); -} - -/* Modal 内可钻取 Tree Table */ -.ehb-modal-table-wrap { - background: #ffffff; - border-radius: 8px; - border: 1px solid #e2e8f0; - overflow: hidden; -} - -.ehb-modal-table-wrap.is-v-scroll { - max-height: min(52vh, 440px); - overflow-y: auto; -} - -.ehb-modal-table { - width: 100%; - border-collapse: collapse; - text-align: left; -} - -.ehb-modal-table th { - background: #f1f5f9; - font-size: 11px; - font-weight: 700; - color: #475569; - padding: 10px 12px; - border-bottom: 1px solid #e2e8f0; - white-space: nowrap; -} - -.ehb-modal-table td { - padding: 10px 12px; - font-size: 12px; - border-bottom: 1px solid #f1f5f9; - white-space: nowrap; -} - -/* Modal 树形表格层级与 H5 换行多行适配 */ -.ehb-modal-table th:first-child, -.ehb-modal-table td:first-child { - min-width: 240px; -} - -.ehb-tree-node-title { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 4px 6px; - line-height: 1.4; -} - -.ehb-tree-node-sub { - font-size: 11px; - color: #64748b; - font-weight: 400; - white-space: normal; -} - -.ehb-tree-ord-block { - display: flex; - flex-direction: column; - gap: 2px; - line-height: 1.3; -} - -.ehb-tree-ord-time { - font-size: 10px; - color: #64748b; - font-weight: 400; -} - -.ehb-tree-cell-l1 { - padding-left: 12px; -} -.ehb-tree-cell-l2 { - padding-left: 28px; -} -.ehb-tree-cell-l3 { - padding-left: 44px; -} -.ehb-tree-cell-l4 { - padding-left: 60px; -} - -/* 真实账本钻取沿用原型的逐层背景层次:站、客户、车辆、订单在同一张表中可辨。 */ -.ehb-tree-row-station td { - background: #e0f2fe; -} - -.ehb-tree-row-customer td { - background: #f1f5f9; -} - -.ehb-modal-table tr:hover td { - background-color: #f8fafc; -} - -/* KPI 点击下钻提示图标/按钮 */ -.ehb-kpi-drill-hint { - font-size: 10px; - color: var(--oneos-primary, #533afd); - background: rgba(83, 58, 253, 0.08); - padding: 2px 6px; - border-radius: 4px; - font-weight: 600; - margin-left: 6px; - display: inline-flex; - align-items: center; - gap: 2px; - transition: all 0.2s ease; -} - -.ehb-kpi-dual:hover .ehb-kpi-drill-hint { - background: #533afd; - color: #ffffff; -} - -.ehb-kpi-dual { - cursor: pointer; - transition: - transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), - box-shadow 0.2s cubic-bezier(0.16, 1, 0.3, 1); -} - -.ehb-kpi-dual:hover { - transform: translateY(-2px); - box-shadow: 0 8px 20px -2px rgba(83, 58, 253, 0.15); -} - -.ehb-kpi-dual:focus-visible, -.ehb-pill-btn:focus-visible, -.ehb-fleet-btn:focus-visible, -.ehb-mini-tab:focus-visible, -.ehb-rail__item:focus-visible { - outline: 2px solid rgba(37, 99, 235, 0.55); - outline-offset: 2px; -} - -@media (hover: none) and (pointer: coarse) { - .ehb-kpi-dual:active, - .ehb-pill-btn:active, - .ehb-fleet-btn:active, - .ehb-mini-tab:active, - .ehb-rail__item:active { - transform: scale(0.98); - } -} - -/* 迷你比例进度条 */ -.ehb-ratio-flex { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 10px; -} - -.ehb-mini-bar-track { - width: 60px; - height: 5px; - background: #f1f5f9; - border-radius: 3px; - overflow: hidden; - position: relative; -} - -.ehb-mini-bar-fill { - height: 100%; - border-radius: 3px; -} - -.ehb-mini-bar-fill.is-blue { - background: #0284c7; -} - -.ehb-mini-bar-fill.is-green { - background: #10b981; -} - -.ehb-ratio-text { - font-size: 11px; - font-family: var(--bi-font-mono); - color: #475569; - min-width: 42px; - text-align: right; -} - -/* 承担方 Badge */ -.ehb-bearer-tag { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 1px 8px; - border-radius: 4px; - font-size: 11px; - font-weight: 600; -} - -.ehb-bearer-tag.is-cust { - color: #d97706; - background: #fffbe3; - border: 1px solid #fde68a; -} - -.ehb-bearer-tag.is-lingniu { - color: #2563eb; - background: #eff6ff; - border: 1px solid #bfdbfe; -} - -.ehb-bearer-tag.is-other { - color: #64748b; - background: #f8fafc; - border: 1px solid #cbd5e1; -} - -/* Top5 站条形图 */ -.ehb-top-stations-list { - display: flex; - flex-direction: column; - gap: 12px; -} - -.ehb-top-station-item { - display: flex; - align-items: center; - gap: 10px; -} - -.ehb-top-rank { - display: flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - border-radius: 50%; - background: #0284c7; - color: #ffffff; - font-size: 11px; - font-weight: 700; - font-family: var(--bi-font-mono); - flex-shrink: 0; -} - -.ehb-top-rank.is-sub { - background: #94a3b8; -} - -.ehb-top-station-name { - font-size: 12px; - font-weight: 600; - color: #1e293b; - width: 150px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - flex-shrink: 0; -} - -.ehb-top-bar-bg { - flex: 1; - height: 12px; - background: #f1f5f9; - border-radius: 6px; - overflow: visible; - position: relative; -} - -.ehb-top-bar-fill { - height: 100%; - display: flex; - overflow: hidden; - border-radius: 6px; - transition: width 0.3s ease; - position: relative; -} - -.ehb-top-bar-seg { - height: 100%; - transition: width 0.2s ease; -} - -.ehb-top-bar-seg.is-own { - background: linear-gradient(90deg, #38bdf8 0%, #0284c7 100%); -} - -.ehb-top-bar-seg.is-ext { - background: linear-gradient(90deg, #fbbf24 0%, #f59e0b 100%); -} - -/* Top5 站加氢量横向 Hover 自定义悬浮卡 */ -.ehb-top-bar-tooltip { - position: absolute; - bottom: calc(100% + 8px); - left: 50%; - transform: translateX(-50%) translateY(4px); - background: rgba(15, 23, 42, 0.94); - backdrop-filter: blur(10px); - color: #ffffff; - padding: 8px 12px; - border-radius: 6px; - box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.35); - font-size: 11px; - white-space: nowrap; - pointer-events: none; - opacity: 0; - visibility: hidden; - transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); - z-index: 30; - min-width: 210px; -} - -.ehb-top-bar-bg:hover .ehb-top-bar-tooltip, -.ehb-top-station-item:hover .ehb-top-bar-tooltip { - opacity: 1; - visibility: visible; - transform: translateX(-50%) translateY(0); -} - -.ehb-top-bar-tooltip__head { - font-weight: 700; - color: #38bdf8; - font-size: 11px; - margin-bottom: 4px; - padding-bottom: 4px; - border-bottom: 1px solid rgba(255, 255, 255, 0.15); -} - -.ehb-top-bar-tooltip__row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - line-height: 1.6; -} - -.ehb-top-bar-tooltip__left { - display: flex; - align-items: center; - gap: 6px; - color: #cbd5e1; -} - -.ehb-top-bar-tooltip__dot { - width: 6px; - height: 6px; - border-radius: 50%; -} - -.ehb-top-bar-tooltip__dot.is-own { - background: #38bdf8; -} - -.ehb-top-bar-tooltip__dot.is-ext { - background: #f59e0b; -} - -.ehb-top-bar-tooltip__val { - font-weight: 700; - font-family: var(--bi-font-mono); - color: #ffffff; -} - -.ehb-top-bar-tooltip__foot { - margin-top: 4px; - padding-top: 4px; - border-top: 1px dashed rgba(255, 255, 255, 0.15); - display: flex; - align-items: center; - justify-content: space-between; - color: #cbd5e1; - font-weight: 700; -} - -.ehb-top-station-val { - font-size: 12px; - font-weight: 700; - font-family: var(--bi-font-mono); - color: #0f172a; - width: 70px; - text-align: right; - flex-shrink: 0; -} - -/* 迷你切换分段页签 (如:按省 / 按市) */ -.ehb-mini-tabs { - display: inline-flex; - align-items: center; - background: #f1f5f9; - border-radius: 6px; - padding: 2px; - gap: 2px; -} - -.ehb-mini-tab { - border: none; - background: transparent; - padding: 2px 10px; - font-size: 11px; - font-weight: 500; - color: #64748b; - border-radius: 4px; - cursor: pointer; - transition: all 0.2s ease; -} - -.ehb-mini-tab.is-active { - background: #ffffff; - color: #0284c7; - font-weight: 700; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); -} - -/* 区域占比 Donut */ -.ehb-donut-section { - display: flex; - align-items: center; - gap: 20px; -} - -.ehb-donut-chart-wrap { - position: relative; - width: 130px; - height: 130px; - flex-shrink: 0; -} - -.ehb-donut-center-text { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - text-align: center; -} - -.ehb-donut-center-text .title { - font-size: 10px; - color: #64748b; -} - -.ehb-donut-center-text .val { - font-size: 13px; - font-weight: 800; - color: #0f172a; - font-family: var(--bi-font-mono); -} - -.ehb-region-legend-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 8px 16px; - flex: 1; -} - -.ehb-region-legend-item { - display: flex; - align-items: center; - justify-content: space-between; - font-size: 11px; -} - -.ehb-region-legend-left { - display: flex; - align-items: center; - gap: 6px; - color: #334155; -} - -.ehb-region-dot { - width: 7px; - height: 7px; - border-radius: 50%; - flex-shrink: 0; -} - -.ehb-region-legend-val { - font-weight: 700; - font-family: var(--bi-font-mono); - color: #0f172a; -} - -@media (max-width: 1100px) { - /* 平板宽度不能继续沿用 5 张 KPI 同行的桌面密度。 */ - .ehb-rail { - width: 64px; - } - - .ehb-rail__item { - width: 52px; - } - - .ehb-body { - padding: 14px 16px 28px; - } - - .ehb-host-kpi { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - - .ehb-insight { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .ehb-insight__card:last-child { - grid-column: 1 / -1; - } - - .ehb-daily-kpi-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .ehb-daily-filter-row { - align-items: stretch; - } - - .ehb-daily-filter-group:last-child { - justify-content: space-between; - } - - .ehb-chart-box, - .ehb-sum-table-card { - padding: 14px; - } - - /* 平板同样会出现宽表,不能仅在手机端才允许横向查看。 */ - .ehb-modal-table-wrap { - overflow-x: auto; - -webkit-overflow-scrolling: touch; - box-shadow: inset -8px 0 10px -8px rgba(15, 23, 42, 0.32); - } - - .ehb-modal-table { - min-width: 820px; - } - - .ehb-h5-scroll-hint { - display: block; - margin: 0 0 6px; - padding: 4px 8px; - border-radius: 4px; - background: rgba(2, 132, 199, 0.08); - color: #0284c7; - font-size: 11px; - font-weight: 500; - text-align: center; - } - - .ehb-two-charts-row { - grid-template-columns: 1fr; - } -} - -/* 8113 原型:真实账本下钻弹层视觉合同。 */ -.ehb-drill-modal--unified .ehb-modal-body { - padding: 18px; - background: #f6f8fb; -} - -.ehb-drill-modal--unified .ehb-drill-root-tabs { - display: flex; - justify-content: flex-end; - margin: 0 0 12px; -} - -.ehb-drill-modal--unified .ehb-modal-meta-bar { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 0; - padding: 0; - overflow: hidden; - border: 1px solid #dfe7f0; - border-radius: 8px; - background: #fff; -} - -.ehb-drill-modal--unified .ehb-modal-meta-item { - min-width: 0; - padding: 12px 16px; - border-right: 1px solid #dfe7f0; -} - -.ehb-drill-modal--unified .ehb-modal-meta-item:last-child { border-right: 0; } -.ehb-drill-modal--unified .ehb-modal-meta-label { color: #64748b; } -.ehb-drill-modal--unified .ehb-modal-meta-val { - color: #172238; - font-family: var(--bi-font-mono); - font-weight: 750; -} - -.ehb-drill-modal--unified .ehb-modal-filter-row { - gap: 8px; - padding: 10px 12px; - margin: 12px 0; - border: 1px solid #dfe7f0; - border-radius: 8px; - background: #fff; -} - -.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; -} - -.ehb-drill-modal--unified .ehb-modal-table th { - height: 44px; - padding: 10px 12px; - border-bottom-color: #d7e1ed; - background: #f0f3f8; - color: #52627a; -} - -.ehb-drill-modal--unified .ehb-modal-table > tbody > tr > td { - height: 48px; - padding-block: 10px; - border-bottom-color: #e5ebf2; - color: #334155; -} - -.ehb-drill-modal--unified .ehb-drill-group-row--station > td { background: #edf3fa; } -.ehb-drill-modal--unified .ehb-drill-group-row--customer > td { background: #f5f7fa; } -.ehb-drill-modal--unified .ehb-drill-group-row--vehicle > td { background: #fafbfd; } -.ehb-drill-modal--unified .ehb-drill-group-row:hover > td { background: #e8f0fb; } - -.ehb-drill-modal--unified .ehb-tree-toggle { - min-width: 18px; - color: #2f6bff; - font-weight: 800; -} - -.ehb-drill-modal--unified .ehb-kpi-drill-hint { - color: #2f6bff; - background: #eaf1ff; -} - -.ehb-drill-modal--unified .ehb-modal-table td[style*="text-align: right"] { - color: #334155; - font-family: var(--bi-font-mono); - font-variant-numeric: tabular-nums; -} - -.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-volume { - color: #2f6bff !important; - font-weight: 750; -} - -.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-income { - color: #2c8a78 !important; - font-weight: 750; -} - -.ehb-drill-modal--unified .ehb-bearer-tag.is-cust { - color: #b86606; - border: 1px solid #f2d26c; - background: #fffbea; -} - -.ehb-drill-modal--unified .ehb-bearer-tag.is-lingniu { - color: #2f6bff; - border: 1px solid #bdd0fb; - background: #eef4ff; -} - -.ehb-drill-modal--unified .ehb-bearer-tag.is-other { - color: #64748b; - border: 1px solid #cbd5e1; - background: #f8fafc; -} - -.ehb-drill-modal--unified .ehb-tag--source-api, -.ehb-drill-modal--unified .ehb-tag--source-station, -.ehb-drill-modal--unified .ehb-tag--source-lingniu { - border: 1px solid #cbd9e8; - background: #eef4f9; - color: #526b88; -} - -@media (max-width: 760px) { - .ehb-drill-modal--unified .ehb-modal-meta-bar { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - .ehb-drill-modal--unified .ehb-modal-meta-item:nth-child(2) { border-right: 0; } - .ehb-drill-modal--unified .ehb-modal-meta-item:nth-child(-n + 2) { border-bottom: 1px solid #dfe7f0; } -} - -.ehb-h5-scroll-hint { - display: none; -} - -@media (max-width: 767px) { - .ehb-shell { - display: block; - width: 100%; - max-width: 100%; - overflow-x: clip; - } - - .ehb-rail { - display: none; - } - .ehb-body { - padding: 10px 10px 24px; - width: 100%; - max-width: 100%; - overflow-x: clip; - } - .ehb-chrome { - margin: -6px -6px 10px; - padding: 8px 10px; - } - .ehb-chrome__lead { - flex-direction: column; - align-items: flex-start !important; - gap: 6px !important; - } - .ehb-chrome__lead h1 { - font-size: 17px; - } - .ehb-time-range-pill { - font-size: 11px !important; - padding: 3px 8px !important; - } - .ehb-daily-kpi-grid, - .ehb-host-kpi { - display: grid !important; - grid-template-columns: repeat(2, minmax(0, 1fr)) !important; - gap: 8px !important; - margin-bottom: 12px !important; - } - - .ehb-host, - .ehb-daily-kpi-grid, - .ehb-host-kpi { - min-width: 0 !important; - } - - .ehb-daily-kpi-card, - .ehb-kpi-dual { - padding: 10px 10px !important; - box-sizing: border-box !important; - min-width: 0 !important; - } - - .ehb-daily-kpi-head, - .ehb-kpi-dual__head { - margin-bottom: 4px !important; - } - - .ehb-daily-kpi-title, - .ehb-kpi-dual__label { - font-size: 11px !important; - white-space: nowrap !important; - overflow: hidden !important; - text-overflow: ellipsis !important; - } - - .ehb-kpi-dual__num { - font-size: 18px !important; - line-height: 1.2 !important; - font-weight: 700 !important; - } - - .ehb-kpi-dual__unit { - font-size: 11px !important; - margin-left: 2px !important; - } - - .ehb-daily-kpi-sub, - .ehb-kpi-dual__footer { - font-size: 10px !important; - line-height: 1.3 !important; - color: #64748b !important; - word-break: break-all !important; - margin-top: 4px !important; - } - - .ehb-insight { - grid-template-columns: 1fr !important; - } - .ehb-filters__rule { - display: none; - } - - /* 下钻头部仅保留返回/关闭,按日导出仍必须可用。 */ - .ehb-modal-head__actions .ehb-btn { - display: none !important; - } - - /* 1. H5 过滤卡片与按键整齐防错行 */ - .ehb-daily-filter-card { - padding: 10px !important; - min-width: 0 !important; - } - .ehb-daily-filter-row { - flex-direction: column !important; - align-items: stretch !important; - gap: 10px !important; - } - .ehb-daily-filter-group { - flex-wrap: wrap !important; - gap: 8px !important; - width: 100% !important; - justify-content: space-between !important; - } - - /* 日期范围是一组输入:移动端保持同一行,避免用户误解为两个独立筛选。 */ - .ehb-daily-filter-group .ehb-modal-select[type="date"] { - width: calc(50% - 4px) !important; - min-width: 0 !important; - } - .ehb-fleet-segmented { - width: 100% !important; - display: flex !important; - box-sizing: border-box !important; - } - .ehb-fleet-btn { - flex: 1 !important; - min-width: 0 !important; - justify-content: center !important; - text-align: center !important; - padding: 0 4px !important; - font-size: 12px !important; - height: 36px !important; - min-height: 36px !important; - white-space: nowrap !important; - } - .ehb-fleet-btn svg { - display: none !important; /* H5 隐藏车辆 Icon 腾出空间防字折断 */ - } - .ehb-pill-tabs { - width: 100% !important; - display: flex !important; - box-sizing: border-box !important; - } - .ehb-pill-btn { - flex: 1 !important; - min-width: 0 !important; - justify-content: center !important; - text-align: center !important; - padding: 0 4px !important; - font-size: 12px !important; - height: 36px !important; - min-height: 36px !important; - white-space: nowrap !important; - } - .ehb-year-select-wrapper { - width: 100% !important; - } - .ehb-year-btn { - width: 100% !important; - height: 36px !important; - justify-content: space-between !important; - } - .ehb-dp-trigger { - height: 36px !important; - padding: 0 8px !important; - font-size: 12px !important; - flex: 1 !important; - } - - /* 2. H5 Modal 下钻全屏沉浸 */ - .ehb-modal-overlay { - padding: 0 !important; - align-items: flex-start !important; - justify-content: flex-start !important; - z-index: 9999 !important; - top: 0 !important; - left: 0 !important; - right: 0 !important; - bottom: 0 !important; - overflow: hidden !important; - } - - .ehb-modal-card { - width: 100vw !important; - max-width: 100vw !important; - height: 100% !important; - height: 100dvh !important; - max-height: 100dvh !important; - border-radius: 0 !important; - border: none !important; - box-shadow: none !important; - display: flex !important; - flex-direction: column !important; - } - - .ehb-modal-head { - padding-top: max(10px, env(safe-area-inset-top, 10px)) !important; - padding-bottom: 10px !important; - padding-left: 12px !important; - padding-right: 12px !important; - min-height: 52px !important; - background: #0f172a !important; - box-sizing: border-box !important; - flex-shrink: 0 !important; - } - - .ehb-modal-head__title-group { - display: flex !important; - align-items: center !important; - gap: 8px !important; - flex: 1 !important; - min-width: 0 !important; - } - - .ehb-modal-head__title-group > div { - flex: 1 !important; - min-width: 0 !important; - } - - .ehb-modal-head__title { - font-size: 13px !important; - line-height: 1.3 !important; - font-weight: 700 !important; - white-space: nowrap !important; - overflow: hidden !important; - text-overflow: ellipsis !important; - } - - .ehb-modal-head__sub { - font-size: 10px !important; - color: #94a3b8 !important; - white-space: nowrap !important; - overflow: hidden !important; - text-overflow: ellipsis !important; - margin-top: 2px !important; - display: block !important; - -webkit-line-clamp: unset !important; - } - - .ehb-modal-back-btn { - padding: 4px 8px !important; - font-size: 12px !important; - min-height: 32px !important; - height: 32px !important; - flex-shrink: 0 !important; - } - - .ehb-modal-body { - padding: 10px 10px 20px !important; - } - - .ehb-modal-meta-bar { - grid-template-columns: repeat(2, 1fr) !important; - gap: 6px !important; - padding: 8px !important; - } - - .ehb-modal-meta-item { - padding: 4px 6px !important; - } - - .ehb-modal-meta-label { - font-size: 10px !important; - } - - .ehb-modal-meta-val { - font-size: 13px !important; - } - - .ehb-modal-filter-row { - flex-direction: column !important; - align-items: stretch !important; - gap: 8px !important; - padding: 8px !important; - } - - .ehb-modal-filter-group { - width: 100% !important; - gap: 8px !important; - } - - .ehb-modal-filter-group select { - flex: 1 !important; - min-width: 0 !important; - } - - /* 3. 统一 H5 控件高度 36px,弱化提示卡占空间 */ - .ehb-modal-search-input, - .ehb-modal-select, - .ehb-bi-search-select, - .ehb-bi-search-select__trigger { - width: 100% !important; - height: 36px !important; - min-height: 36px !important; - font-size: 12px !important; - box-sizing: border-box !important; - } - - .ehb-modal-hint-text { - font-size: 11px !important; - font-weight: 400 !important; - color: var(--bi-tertiary, #94a3b8) !important; - line-height: 1.4 !important; - margin: 2px 0 !important; - } - - .ehb-modal-hint-text strong { - color: inherit !important; - font-weight: 400 !important; - } - - /* 钻取表 100% 支撑横滚与右侧财务/状态列可见,第一列支持多行(2-3行)无缝自适应 */ - .ehb-modal-table-wrap { - width: 100% !important; - overflow-x: auto !important; - -webkit-overflow-scrolling: touch !important; - display: block !important; - border-radius: 8px !important; - border: 1px solid #e2e8f0 !important; - box-shadow: inset -6px 0 8px -4px rgba(15, 23, 42, 0.1) !important; - } - - .ehb-modal-table { - min-width: 820px !important; - table-layout: auto !important; - } - - .ehb-modal-table th, - .ehb-modal-table td { - padding: 8px 8px !important; - font-size: 11px !important; - white-space: normal !important; - word-break: break-word !important; - } - - .ehb-modal-table th:first-child, - .ehb-modal-table td:first-child { - min-width: 250px !important; - } - - /* H5 移动端紧凑树结构缩进 */ - .ehb-tree-cell-l1 { - padding-left: 6px !important; - } - .ehb-tree-cell-l2 { - padding-left: 16px !important; - } - .ehb-tree-cell-l3 { - padding-left: 26px !important; - } - .ehb-tree-cell-l4 { - padding-left: 36px !important; - } - - .ehb-h5-scroll-hint { - display: block !important; - font-size: 11px !important; - color: #0284c7 !important; - background: rgba(2, 132, 199, 0.08) !important; - padding: 4px 8px !important; - border-radius: 4px !important; - margin-bottom: 6px !important; - text-align: center !important; - font-weight: 500 !important; - } - - /* H5 下日期 Popover 与年份 Select 转 Bottom Sheet */ - .ehb-date-popover, - .ehb-year-dropdown { - position: fixed !important; - bottom: 0 !important; - left: 0 !important; - right: 0 !important; - top: auto !important; - width: 100vw !important; - max-width: 100vw !important; - border-radius: 16px 16px 0 0 !important; - box-shadow: 0 -10px 30px rgba(15, 23, 42, 0.3) !important; - z-index: 10000 !important; - animation: ehbSlideUpSheet 0.25s cubic-bezier(0.16, 1, 0.3, 1) !important; - } - - @keyframes ehbSlideUpSheet { - from { - transform: translateY(100%); - } - to { - transform: translateY(0); - } - } - - /* 图表头部与图例 H5 上下分行两端对齐,彻底消除图例重叠挤压 */ - .ehb-daily-chart-head, - .ehb-chart-box-head { - flex-direction: column !important; - align-items: flex-start !important; - gap: 8px !important; - margin-bottom: 10px !important; - } - - .ehb-daily-chart-title, - .ehb-chart-box-title { - width: 100% !important; - } - - .ehb-daily-chart-meta-group { - width: 100% !important; - display: flex !important; - align-items: center !important; - justify-content: space-between !important; - gap: 8px !important; - } - - .ehb-daily-chart-legend, - .ehb-chart-legend-inline { - display: flex !important; - align-items: center !important; - gap: 10px !important; - } - - .ehb-legend-item, - .ehb-chart-legend-tag { - font-size: 11px !important; - } - - .ehb-daily-chart-meta, - .ehb-chart-box-meta { - font-size: 10px !important; - color: #64748b !important; - white-space: nowrap !important; - } - - /* 图表与表格在 H5 下的自适应 */ - .ehb-daily-bar-container { - overflow-x: auto !important; - overflow-y: hidden !important; - -webkit-overflow-scrolling: touch !important; - padding-top: 28px !important; - padding-bottom: 24px !important; - gap: 10px !important; - } - - .ehb-daily-bar-col { - flex: 0 0 46px !important; - min-width: 46px !important; - max-width: 46px !important; - } - - .ehb-daily-bar-fill { - width: 24px !important; - max-width: 24px !important; - } - - .ehb-daily-bar-val { - font-size: 11px !important; - font-weight: 600 !important; - top: -22px !important; - } - - .ehb-daily-bar-label { - font-size: 11px !important; - margin-top: 6px !important; - white-space: nowrap !important; - } - - .ehb-mbar-chart, - .ehb-rev-chart { - overflow-x: auto !important; - -webkit-overflow-scrolling: touch !important; - padding-bottom: 6px !important; - } - - .ehb-mbar-col { - min-width: 42px !important; - } - - .ehb-rev-col-group { - min-width: 52px !important; - } - - .ehb-donut-section { - flex-direction: column !important; - align-items: center !important; - } - - .ehb-region-legend-grid { - grid-template-columns: repeat(2, 1fr) !important; - width: 100% !important; - } - - .ehb-sum-table-card__head { - flex-direction: column !important; - align-items: flex-start !important; - gap: 8px !important; - } - - .ehb-mini-tabs { - overflow-x: auto !important; - max-width: 100% !important; - padding-bottom: 2px; - } - - /* 提示文案 H5 适配:单行显示不跨行 */ - .ehb-show-h5 { - display: inline !important; - } - - .ehb-hide-h5 { - display: none !important; - } - - .ehb-daily-table-title, - .ehb-daily-chart-title { - display: flex !important; - align-items: center !important; - flex-wrap: nowrap !important; - white-space: nowrap !important; - overflow: hidden !important; - max-width: 100% !important; - } - - .ehb-daily-table-head { - align-items: flex-start !important; - flex-direction: column !important; - gap: 8px !important; - } - - .ehb-export-btn, - .ehb-daily-export-btn { - display: inline-flex !important; - min-height: 36px !important; - margin-top: 0 !important; - align-self: flex-start; - } - - .ehb-title-sub { - font-size: 11px !important; - color: var(--bi-tertiary) !important; - margin-left: 4px !important; - white-space: nowrap !important; - overflow: hidden !important; - text-overflow: ellipsis !important; - } - - .ehb-daily-level-tag { - padding: 2px 4px; - font-size: 9px; - } - - .ehb-daily-disclosure { - width: 15px; - height: 15px; - margin-right: 4px; - } - - .ehb-daily-date-row td:first-child, - .ehb-tree-cell-l1 { - white-space: nowrap; - } - - /* 4. H5 按住/悬浮图标 Tooltip 沉浸居中/屏内安全弹出,100% 绝对不超出屏外 */ - .ehb-mbar-col:hover .ehb-mbar-tooltip, - .ehb-rev-bar.is-income:hover .ehb-rev-income-tooltip, - .ehb-rev-bar.is-cost:hover .ehb-rev-cost-tooltip, - .ehb-top-bar-bg:hover .ehb-top-bar-tooltip, - .ehb-top-station-item:hover .ehb-top-bar-tooltip { - position: fixed !important; - top: 50% !important; - left: 50% !important; - right: auto !important; - bottom: auto !important; - transform: translate(-50%, -50%) !important; - width: calc(100vw - 32px) !important; - max-width: 320px !important; - z-index: 10002 !important; - box-shadow: 0 16px 40px rgba(15, 23, 42, 0.5) !important; - pointer-events: none; - animation: ehbTooltipCenterFade 0.2s ease-out !important; - } - - @keyframes ehbTooltipCenterFade { - from { - opacity: 0; - transform: translate(-50%, -46%); - } - to { - opacity: 1; - transform: translate(-50%, -50%); - } - } -} - -/* ===== 站日报 + 现结进账(体系A)· 增量,不覆盖既有 .ehb-table ===== */ -.ehb-seg--wrap { - display: flex; - flex-wrap: wrap; - grid-template-columns: none; - min-width: 0; - gap: 3px; -} -.ehb-seg--wrap button { - flex: 0 0 auto; - min-width: 64px; -} -.ehb-cash-banner { - display: flex; - flex-direction: column; - gap: 4px; - padding: 10px 14px; - border-radius: 10px; - background: #f0f9ff; - border: 1px solid #bae6fd; - color: #0c4a6e; - font-size: 12px; - line-height: 1.5; - margin-bottom: 12px; -} -.ehb-cash-banner strong { - font-size: 13px; - color: #0369a1; -} -.ehb-field-label { - display: flex; - flex-direction: column; - gap: 4px; - font-size: 12px; - color: #64748b; - font-weight: 600; -} -.ehb-native-select, -.ehb-native-input { - min-height: 36px; - height: 36px; - border: 1px solid #e2e8f0; - border-radius: 8px; - padding: 0 10px; - font-size: 13px; - color: #0f172a; - background: #fff; - min-width: 160px; -} -.ehb-native-input.is-num { - text-align: right; - font-variant-numeric: tabular-nums; -} -.ehb-btn--primary { - background: #0284c7; - color: #fff; - border-color: #0284c7; -} -.ehb-btn--primary:hover { - background: #0369a1; - color: #fff; - border-color: #0369a1; -} -.ehb-sd-card { - background: #fff; - border: 1px solid #e2e8f0; - border-radius: 12px; - overflow: hidden; -} -.ehb-table-card { - background: #fff; - border: 1px solid #e2e8f0; - border-radius: 12px; - overflow: hidden; -} -.ehb-sd-card__head, -.ehb-table-card__head { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 8px; - padding: 12px 14px; - border-bottom: 1px solid #f1f5f9; -} -.ehb-sd-card__title, -.ehb-table-card__title { - font-size: 14px; - font-weight: 700; - color: #0f172a; -} -.ehb-sd-card__hint, -.ehb-table-card__hint { - font-size: 11px; - color: #94a3b8; -} -.ehb-sd-scroll, -.ehb-table-scroll { - overflow: auto; -} -.ehb-empty-cell { - text-align: center !important; - color: #94a3b8; - padding: 28px 12px !important; -} -.ehb-row-actions { - display: flex; - gap: 10px; - flex-wrap: wrap; -} -.ehb-link-btn { - display: inline-flex; - align-items: center; - gap: 4px; - background: none; - border: none; - color: #0284c7; - font-size: 12px; - font-weight: 600; - cursor: pointer; - padding: 0; -} -.ehb-link-btn.is-danger { - color: #dc2626; -} -.ehb-muted-hint { - font-size: 12px; - color: #94a3b8; - align-self: flex-end; - padding-bottom: 6px; -} -.ehb-kpi-grid--4 { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 12px; -} -.ehb-kpi-card { - background: #fff; - border: 1px solid #e2e8f0; - border-radius: 12px; - padding: 14px 16px; -} -.ehb-kpi-card__label { - font-size: 12px; - color: #64748b; - font-weight: 600; - margin-bottom: 6px; -} -.ehb-kpi-card__value { - font-size: 22px; - font-weight: 800; - color: #0f172a; - font-variant-numeric: tabular-nums; - line-height: 1.2; -} -.ehb-kpi-card__sub { - margin-top: 6px; - font-size: 12px; - color: #94a3b8; -} -.ehb-kpi-unit { - font-size: 13px; - font-weight: 600; - margin-left: 4px; - color: #64748b; -} -.ehb-station-trend { - display: flex; - gap: 8px; - overflow-x: auto; - padding: 8px 4px 4px; - min-height: 160px; - align-items: flex-end; -} -.ehb-station-trend__col { - flex: 0 0 48px; - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; -} -.ehb-station-trend__val { - font-size: 11px; - color: #64748b; - font-variant-numeric: tabular-nums; -} -.ehb-station-trend__bar-wrap { - width: 100%; - height: 110px; - display: flex; - align-items: flex-end; - justify-content: center; -} -.ehb-station-trend__bar { - width: 22px; - border-radius: 4px 4px 2px 2px; - background: linear-gradient(180deg, #60a5fa, #2563eb); -} -.ehb-station-trend__date { - font-size: 11px; - color: #94a3b8; -} -.ehb-dual-tables { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; -} -.ehb-tag--own { - background: #e0f2fe; - color: #0369a1; -} -.ehb-tag--ext { - background: #fff7ed; - color: #c2410c; -} -.ehb-cash-modal { - max-width: 720px; - width: calc(100% - 24px); -} -.ehb-cash-modal-note { - font-size: 12px; - color: #0369a1; - background: #f0f9ff; - border-radius: 8px; - padding: 8px 10px; - margin: 0 0 12px; -} -.ehb-form-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; - margin-bottom: 14px; -} -.ehb-form-grid label { - display: flex; - flex-direction: column; - gap: 6px; - font-size: 12px; - font-weight: 600; - color: #64748b; -} -.ehb-form-span2 { - grid-column: 1 / -1; -} -.ehb-cash-lines-head { - display: flex; - align-items: center; - justify-content: space-between; - margin: 8px 0; - font-size: 13px; - font-weight: 700; - color: #0f172a; -} -.ehb-manual-total { - display: flex; - flex-direction: column; - gap: 6px; - font-size: 12px; - font-weight: 600; - color: #64748b; -} -.ehb-cash-modal-foot { - display: flex; - justify-content: flex-end; - gap: 8px; - padding: 12px 16px; - border-top: 1px solid #f1f5f9; - background: #fff; -} -.ehb-cash-modal .ehb-modal-body { - overflow: auto; - max-height: min(70vh, 560px); - padding: 16px; -} -.ehb-toast { - position: fixed; - bottom: 24px; - left: 50%; - transform: translateX(-50%); - background: #0f172a; - color: #fff; - padding: 10px 16px; - border-radius: 999px; - font-size: 13px; - z-index: 10050; - box-shadow: 0 8px 24px rgba(15, 23, 42, 0.25); -} -.ehb-table .is-num, -.ehb-sum-table .is-num { - text-align: right; - font-variant-numeric: tabular-nums; -} -.ehb-table .is-mono { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 12px; -} -.ehb-table tr.is-total td { - font-weight: 700; - background: #f8fafc; -} -@media (max-width: 767px) { - .ehb-kpi-grid--4 { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - .ehb-dual-tables { - grid-template-columns: 1fr; - } - .ehb-form-grid { - grid-template-columns: 1fr; - } - .ehb-station-trend__col { - flex-basis: 46px; - } - - /* Keep Hydrogen BI touch controls readable and reliably tappable. */ - .ehb-seg button, - .ehb-year-select-btn, - .ehb-btn, - .ehb-pill-btn, - .ehb-fleet-btn { - min-height: 40px !important; - } - - .ehb-seg button, - .ehb-year-select-btn, - .ehb-btn { - height: 40px !important; - } - - .ehb-year-dropdown__item { - min-height: 40px; - } - - .ehb-mini-tab { - min-height: 36px; - } - - .ehb-top-station-item { - min-height: 36px; - padding: 4px 0; - } - - .ehb-region-legend-item { - min-height: 32px; - padding: 4px 0; - } - - .ehb-mbar-col, - .ehb-rev-bar { - position: relative; - } - - .ehb-mbar-col::after, - .ehb-rev-bar::after { - content: ""; - position: absolute; - inset: -8px -6px -6px; - } -} - -/* Runtime: keep the daily-table affordance visible on tablet and phone layouts. */ -@media (max-width: 1100px) { - .ehb-daily-table-scroll-hint { - display: block !important; - margin: 0 0 6px !important; - padding: 4px 8px; - border-radius: 4px; - background: rgba(2, 132, 199, 0.08); - color: #0284c7; - font-size: 11px; - font-weight: 500; - text-align: center; - } -} diff --git a/src/server/mileage-db.ts b/src/server/mileage-db.ts deleted file mode 100644 index a6f68e0..0000000 --- a/src/server/mileage-db.ts +++ /dev/null @@ -1,17 +0,0 @@ -import mysql from 'mysql2/promise'; -import dotenv from 'dotenv'; - -dotenv.config(); - -const mileagePool = mysql.createPool({ - host: process.env.MILEAGE_DB_HOST || '101.133.130.65', - port: Number(process.env.MILEAGE_DB_PORT) || 3306, - user: process.env.MILEAGE_DB_USER || 'bi_reader_02', - password: process.env.MILEAGE_DB_PASSWORD || 'bi_reader_02_Pass', - database: process.env.MILEAGE_DB_NAME || 'hydrogen_energy', - waitForConnections: true, - connectionLimit: 5, - queueLimit: 0, -}); - -export default mileagePool; diff --git a/src/vendor/lnbi-8113-exact/prototypes/energy-h2-bi-board/EnergyBiAccessGate.tsx b/src/vendor/lnbi-8113-exact/prototypes/energy-h2-bi-board/EnergyBiAccessGate.tsx deleted file mode 100644 index 75856b5..0000000 --- a/src/vendor/lnbi-8113-exact/prototypes/energy-h2-bi-board/EnergyBiAccessGate.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React, { useState } from 'react'; -import './styles/energy-bi-board.css'; - -export const ENERGY_BI_PASSWORD = 'lingniu'; -export const ENERGY_BI_AUTH_KEY = 'energy-h2-bi-board-auth-v1'; - -export function isEnergyBiAuthed(): boolean { - try { - return sessionStorage.getItem(ENERGY_BI_AUTH_KEY) === '1'; - } catch { - return false; - } -} - -export function setEnergyBiAuthed(ok: boolean): void { - try { - if (ok) sessionStorage.setItem(ENERGY_BI_AUTH_KEY, '1'); - else sessionStorage.removeItem(ENERGY_BI_AUTH_KEY); - } catch { - /* ignore */ - } -} - -interface EnergyBiAccessGateProps { - onOk: () => void; -} - -/** 轻门禁:口令 lingniu · 本会话记住(与汇报舱 / 作战室同口径) */ -export const EnergyBiAccessGate: React.FC = ({ onOk }) => { - const [pwd, setPwd] = useState(''); - const [err, setErr] = useState(''); - - const submit = (e: React.FormEvent) => { - e.preventDefault(); - if (pwd.trim() === ENERGY_BI_PASSWORD) { - setEnergyBiAuthed(true); - setErr(''); - onOk(); - return; - } - setErr('口令不对,请重试'); - }; - - return ( -
-
-

ONEOS · 能源 BI

-

氢能经营看板

-

我司成本 · 按日 / 总览 · 单站日报

- - { - setPwd(e.target.value); - if (err) setErr(''); - }} - placeholder="请输入口令" - /> -

- {err} -

- -

内部文件 · 请勿外传

-
-
- ); -}; diff --git a/src/vendor/lnbi-8113-exact/prototypes/energy-h2-bi-board/index.tsx b/src/vendor/lnbi-8113-exact/prototypes/energy-h2-bi-board/index.tsx deleted file mode 100644 index 54a638e..0000000 --- a/src/vendor/lnbi-8113-exact/prototypes/energy-h2-bi-board/index.tsx +++ /dev/null @@ -1,50 +0,0 @@ -/** - * @name 能源氢费经营看板 - * @description 嵌入 bi-next #hydrogen/overview · 我司成本三维度(非 OneOS V2) · 口令 lingniu - */ -import React, { useEffect, useMemo, useState } from 'react'; -import { createRoot } from 'react-dom/client'; -import { - type AnnotationSourceDocument, - type AnnotationViewerOptions, -} from '@axhub/annotation'; -import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host'; -import { clearHostPrototypeRouteInfo } from '../../common/useHashPage'; -import { EnergyBiAccessGate, isEnergyBiAuthed } from './EnergyBiAccessGate'; -import { EnergyBiBoardApp } from './EnergyBiBoardApp'; -import annotationSourceDocument from './annotation-source.json'; - -function AuthedEnergyBiBoard() { - const [ok, setOk] = useState(() => isEnergyBiAuthed()); - if (!ok) return setOk(true)} />; - return ; -} - -export default function EnergyH2BiBoardEntry() { - useEffect(() => { - clearHostPrototypeRouteInfo(); - }, []); - - const annotationOptions = useMemo( - () => ({ title: '能源氢费经营看板' }), - [], - ); - - return ( - - - - ); -} - -if (typeof document !== 'undefined' && !window.location.pathname.startsWith('/prototypes/')) { - const container = document.getElementById('root'); - if (container && !container.dataset.energyH2BiBoardMounted) { - container.dataset.energyH2BiBoardMounted = '1'; - const root = createRoot(container); - root.render(); - } -} diff --git a/src/vendor/lnbi-8113-exact/prototypes/energy-h2-station-daily/SdDatePicker.tsx b/src/vendor/lnbi-8113-exact/prototypes/energy-h2-station-daily/SdDatePicker.tsx deleted file mode 100644 index 9756ade..0000000 --- a/src/vendor/lnbi-8113-exact/prototypes/energy-h2-station-daily/SdDatePicker.tsx +++ /dev/null @@ -1,158 +0,0 @@ -/** - * 站日报 · 自定义日期(非原生 type=date),对齐能源 BI 皮 - */ -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { Calendar, ChevronLeft, ChevronRight } from 'lucide-react'; - -function pad2(n: number) { - return n < 10 ? `0${n}` : `${n}`; -} - -function parseYmd(value: string) { - const parts = value.split('-'); - const year = parseInt(parts[0], 10) || 2026; - const month = parseInt(parts[1], 10) || 1; - const day = parseInt(parts[2], 10) || 1; - return { year, month, day }; -} - -function toYmd(year: number, month: number, day: number) { - return `${year}-${pad2(month)}-${pad2(day)}`; -} - -function displayYmd(value: string) { - const { year, month, day } = parseYmd(value); - return `${year}-${pad2(month)}-${pad2(day)}`; -} - -export const SdDatePicker: React.FC<{ - label: string; - value: string; - onChange: (ymd: string) => void; - align?: 'left' | 'right'; -}> = ({ label, value, onChange, align = 'right' }) => { - const [open, setOpen] = useState(false); - const rootRef = useRef(null); - const parsed = useMemo(() => parseYmd(value), [value]); - const [viewYear, setViewYear] = useState(parsed.year); - const [viewMonth, setViewMonth] = useState(parsed.month); - - useEffect(() => { - if (!open) return; - setViewYear(parsed.year); - setViewMonth(parsed.month); - }, [open, parsed.year, parsed.month]); - - useEffect(() => { - if (!open) return; - const onDoc = (e: MouseEvent) => { - if (rootRef.current && !rootRef.current.contains(e.target as Node)) { - setOpen(false); - } - }; - document.addEventListener('mousedown', onDoc); - return () => document.removeEventListener('mousedown', onDoc); - }, [open]); - - const daysInMonth = new Date(viewYear, viewMonth, 0).getDate(); - const firstWeekday = new Date(viewYear, viewMonth - 1, 1).getDay(); - const days = Array.from({ length: daysInMonth }, (_, i) => i + 1); - const blanks = Array.from({ length: firstWeekday }, (_, i) => i); - - const goPrev = (e: React.MouseEvent) => { - e.stopPropagation(); - if (viewMonth === 1) { - setViewYear((y) => y - 1); - setViewMonth(12); - } else { - setViewMonth((m) => m - 1); - } - }; - - const goNext = (e: React.MouseEvent) => { - e.stopPropagation(); - if (viewMonth === 12) { - setViewYear((y) => y + 1); - setViewMonth(1); - } else { - setViewMonth((m) => m + 1); - } - }; - - const pickDay = (day: number, e: React.MouseEvent) => { - e.stopPropagation(); - onChange(toYmd(viewYear, viewMonth, day)); - setOpen(false); - }; - - const pickToday = (e: React.MouseEvent) => { - e.stopPropagation(); - const now = new Date(); - onChange(toYmd(now.getFullYear(), now.getMonth() + 1, now.getDate())); - setOpen(false); - }; - - return ( -
- - - {open ? ( -
-
- -
- {viewYear}年{pad2(viewMonth)}月 -
- -
- -
- {['日', '一', '二', '三', '四', '五', '六'].map((w) => ( - {w} - ))} -
- -
- {blanks.map((i) => ( - - ))} - {days.map((d) => { - const selected = - parsed.year === viewYear && parsed.month === viewMonth && parsed.day === d; - return ( - - ); - })} -
- -
- -
-
- ) : null} -
- ); -}; diff --git a/src/vendor/lnbi-8113-exact/prototypes/energy-h2-station-daily/StationDailyAccessGate.tsx b/src/vendor/lnbi-8113-exact/prototypes/energy-h2-station-daily/StationDailyAccessGate.tsx deleted file mode 100644 index 8e4ec0c..0000000 --- a/src/vendor/lnbi-8113-exact/prototypes/energy-h2-station-daily/StationDailyAccessGate.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import React, { useState } from 'react'; -import '../energy-h2-bi-board/styles/energy-bi-board.css'; -import './styles.css'; - -export const STATION_DAILY_PASSWORD = 'lingniu'; -export const STATION_DAILY_AUTH_KEY = 'energy-h2-station-daily-auth-v1'; - -export function isStationDailyAuthed(): boolean { - try { - return sessionStorage.getItem(STATION_DAILY_AUTH_KEY) === '1'; - } catch { - return false; - } -} - -export function setStationDailyAuthed(ok: boolean): void { - try { - if (ok) sessionStorage.setItem(STATION_DAILY_AUTH_KEY, '1'); - else sessionStorage.removeItem(STATION_DAILY_AUTH_KEY); - } catch { - /* ignore */ - } -} - -export const StationDailyAccessGate: React.FC<{ onOk: () => void }> = ({ onOk }) => { - const [pwd, setPwd] = useState(''); - const [err, setErr] = useState(''); - - const submit = (e: React.FormEvent) => { - e.preventDefault(); - if (pwd.trim() === STATION_DAILY_PASSWORD) { - setStationDailyAuthed(true); - setErr(''); - onOk(); - return; - } - setErr('口令不对,请重试'); - }; - - return ( -
-
-

ONEOS · 能源 BI

-

加氢站日报

-

全站加氢量 · 现结进账

- - { - setPwd(e.target.value); - if (err) setErr(''); - }} - placeholder="请输入口令" - /> -

- {err} -

- -

内部文件 · 请勿外传

-
-
- ); -}; diff --git a/src/vendor/lnbi-8113-exact/prototypes/energy-h2-station-daily/index.tsx b/src/vendor/lnbi-8113-exact/prototypes/energy-h2-station-daily/index.tsx deleted file mode 100644 index 8361a10..0000000 --- a/src/vendor/lnbi-8113-exact/prototypes/energy-h2-station-daily/index.tsx +++ /dev/null @@ -1,49 +0,0 @@ -/** - * @name 加氢站日报 - * @description 加氢站经营日报 · 能源BI皮 · 口令 lingniu - */ -import React, { useEffect, useMemo, useState } from 'react'; -import { createRoot } from 'react-dom/client'; -import { - type AnnotationSourceDocument, - type AnnotationViewerOptions, -} from '@axhub/annotation'; -import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host'; -import { clearHostPrototypeRouteInfo } from '../../common/useHashPage'; -import { StationDailyAccessGate, isStationDailyAuthed } from './StationDailyAccessGate'; -import { StationDailyApp } from './StationDailyApp'; -import annotationSourceDocument from './annotation-source.json'; - -function AuthedStationDaily() { - const [ok, setOk] = useState(() => isStationDailyAuthed()); - if (!ok) return setOk(true)} />; - return ; -} - -export default function EnergyH2StationDailyEntry() { - useEffect(() => { - clearHostPrototypeRouteInfo(); - }, []); - - const annotationOptions = useMemo( - () => ({ title: '加氢站日报' }), - [], - ); - - return ( - - - - ); -} - -if (typeof document !== 'undefined') { - const container = document.getElementById('root'); - if (container && !container.dataset.energyH2StationDailyMounted) { - container.dataset.energyH2StationDailyMounted = '1'; - createRoot(container).render(); - } -} diff --git a/tsconfig.json b/tsconfig.json index 2c15ae4..1a48f18 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,11 +16,5 @@ "@/*": ["./*"] } }, - "include": ["src"], - "exclude": [ - "src/modules/energy/hydrogen-bi-v2/prototype-source/**", - "src/vendor/lnbi-original/**", - "src/vendor/lnbi-8113-exact/prototypes/energy-h2-bi-board/index.tsx", - "src/vendor/lnbi-8113-exact/prototypes/energy-h2-station-daily/index.tsx" - ] + "include": ["src"] }