refactor: unify embedded BI states

This commit is contained in:
kkfluous
2026-08-07 16:37:53 +08:00
parent 671a7a8224
commit 55297a6240
6 changed files with 48 additions and 33 deletions
+1
View File
@@ -150,6 +150,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
- 已完成版本化指标目录和四模块入口。 - 已完成版本化指标目录和四模块入口。
- 已完成里程、电能、氢能主要 URL 状态。 - 已完成里程、电能、氢能主要 URL 状态。
- 已完成 ETC 记录/账单视图、日期、搜索和分页 URL 状态,以及超范围页码自动纠正。 - 已完成 ETC 记录/账单视图、日期、搜索和分页 URL 状态,以及超范围页码自动纠正。
- 已统一共享加载、空数据和故障组件的顶层卡片与明细内联边界,消除能源明细中的嵌套状态卡片。
- 已完成并发同参 GET 去重。 - 已完成并发同参 GET 去重。
### 阶段 B:核心下钻闭环 ### 阶段 B:核心下钻闭环
+25 -4
View File
@@ -233,9 +233,20 @@ export function SkeletonBlock({ className }: { className?: string }) {
return <div className={cn('overflow-hidden rounded-xl bg-slate-100 shimmer', className)} />; return <div className={cn('overflow-hidden rounded-xl bg-slate-100 shimmer', className)} />;
} }
export function LoadingState({ label = '数据加载中' }: { label?: string }) { type StateVariant = 'card' | 'inline';
export function LoadingState({
label = '数据加载中',
variant = 'card',
}: {
label?: string;
variant?: StateVariant;
}) {
return ( return (
<div className="rounded-2xl border border-slate-100 bg-white p-8 text-center shadow-sm"> <div className={cn(
'p-8 text-center',
variant === 'card' && 'rounded-2xl border border-slate-100 bg-white shadow-sm',
)}>
<Loader2 className="mx-auto mb-3 animate-spin text-blue-500" size={22} /> <Loader2 className="mx-auto mb-3 animate-spin text-blue-500" size={22} />
<div className="text-xs font-black text-slate-500">{label}</div> <div className="text-xs font-black text-slate-500">{label}</div>
</div> </div>
@@ -245,12 +256,17 @@ export function LoadingState({ label = '数据加载中' }: { label?: string })
export function EmptyState({ export function EmptyState({
title = '暂无数据', title = '暂无数据',
description = '换个筛选条件或稍后再试', description = '换个筛选条件或稍后再试',
variant = 'card',
}: { }: {
title?: string; title?: string;
description?: string; description?: string;
variant?: StateVariant;
}) { }) {
return ( return (
<div className="rounded-2xl border border-slate-100 bg-white p-8 text-center shadow-sm"> <div className={cn(
'p-8 text-center',
variant === 'card' && 'rounded-2xl border border-slate-100 bg-white shadow-sm',
)}>
<SearchX className="mx-auto mb-3 text-slate-300" size={26} /> <SearchX className="mx-auto mb-3 text-slate-300" size={26} />
<div className="text-sm font-black text-slate-700">{title}</div> <div className="text-sm font-black text-slate-700">{title}</div>
<div className="mt-1 text-xs font-bold text-slate-400">{description}</div> <div className="mt-1 text-xs font-bold text-slate-400">{description}</div>
@@ -263,14 +279,19 @@ export function ErrorState({
title = '加载失败', title = '加载失败',
onRetry, onRetry,
retrying = false, retrying = false,
variant = 'card',
}: { }: {
message: string; message: string;
title?: string; title?: string;
onRetry?: () => void; onRetry?: () => void;
retrying?: boolean; retrying?: boolean;
variant?: StateVariant;
}) { }) {
return ( return (
<div className="rounded-2xl border border-rose-100 bg-rose-50 p-4 text-rose-700 shadow-sm"> <div className={cn(
'bg-rose-50 p-4 text-rose-700',
variant === 'card' && 'rounded-2xl border border-rose-100 shadow-sm',
)}>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<AlertCircle size={18} className="mt-0.5 shrink-0" /> <AlertCircle size={18} className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
+4 -5
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { ChevronLeft, ChevronRight, Loader2, ReceiptText, Route, Search } from 'lucide-react'; import { ChevronLeft, ChevronRight, ReceiptText, Route, Search } from 'lucide-react';
import Blur from '../../components/Blur'; import Blur from '../../components/Blur';
import { ErrorState, LoadingState } from '../../components/ui/surface';
import { fetchEtcBills, fetchEtcRecords } from './api'; import { fetchEtcBills, fetchEtcRecords } from './api';
import type { EtcBillResponse, EtcTollRecordResponse } from './types'; import type { EtcBillResponse, EtcTollRecordResponse } from './types';
import type { EtcDetailView, EtcDrillContext } from './etc-drill-context'; import type { EtcDetailView, EtcDrillContext } from './etc-drill-context';
@@ -165,11 +166,9 @@ export default function ETCDetails({
) : null} ) : null}
{error ? ( {error ? (
<div className="m-4 rounded-lg border border-rose-100 bg-rose-50 p-3 text-xs font-bold text-rose-700">{error}</div> <ErrorState message={error} variant="inline" />
) : loading && !activeData ? ( ) : loading && !activeData ? (
<div className="flex items-center justify-center gap-2 py-14 text-xs font-bold text-slate-400"> <LoadingState label="正在加载 ETC 明细" variant="inline" />
<Loader2 size={16} className="animate-spin" /> ETC
</div>
) : view === 'records' && records ? ( ) : view === 'records' && records ? (
records.items.length === 0 ? ( records.items.length === 0 ? (
<div className="py-14 text-center"> <div className="py-14 text-center">
+6 -6
View File
@@ -311,11 +311,11 @@ export default function ElectricDaily() {
<span className="text-right"></span> <span className="text-right"></span>
</div> </div>
{error ? ( {error ? (
<div className="p-3"><ErrorState message={error} /></div> <ErrorState message={error} variant="inline" />
) : months === null ? ( ) : months === null ? (
<div className="p-3"><LoadingState label="正在加载充电明细" /></div> <LoadingState label="正在加载充电明细" variant="inline" />
) : months.length === 0 ? ( ) : months.length === 0 ? (
<div className="p-3"><EmptyState title="暂无充电数据" description="请切换时间范围或车辆归属" /></div> <EmptyState title="暂无充电数据" description="请切换时间范围或车辆归属" variant="inline" />
) : months.map(m => { ) : months.map(m => {
const open = openMonths.has(m.month); const open = openMonths.has(m.month);
return ( return (
@@ -369,11 +369,11 @@ export default function ElectricDaily() {
{selectedDate === d.date && ( {selectedDate === d.date && (
<div className="border-t border-blue-100 bg-blue-50/40 px-3 py-3 md:pl-9"> <div className="border-t border-blue-100 bg-blue-50/40 px-3 py-3 md:pl-9">
{ordersError ? ( {ordersError ? (
<ErrorState message={ordersError} /> <ErrorState message={ordersError} variant="inline" />
) : !orders || orders.date !== d.date ? ( ) : !orders || orders.date !== d.date ? (
<LoadingState label="正在加载充电订单" /> <LoadingState label="正在加载充电订单" variant="inline" />
) : orders.items.length === 0 ? ( ) : orders.items.length === 0 ? (
<EmptyState title="当日无充电订单" description="当前车辆归属下没有订单明细" /> <EmptyState title="当日无充电订单" description="当前车辆归属下没有订单明细" variant="inline" />
) : ( ) : (
<div className="overflow-hidden rounded-lg border border-blue-100 bg-white"> <div className="overflow-hidden rounded-lg border border-blue-100 bg-white">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-blue-100 bg-blue-50 px-3 py-2 text-[10px] font-bold text-blue-700"> <div className="flex flex-wrap items-center justify-between gap-2 border-b border-blue-100 bg-blue-50 px-3 py-2 text-[10px] font-bold text-blue-700">
+2 -2
View File
@@ -472,9 +472,9 @@ export default function HydrogenDaily() {
</div> </div>
{/* 主行 + 子行 */} {/* 主行 + 子行 */}
{rows === null ? ( {rows === null ? (
<div className="p-3"><LoadingState label="正在加载加氢明细" /></div> <LoadingState label="正在加载加氢明细" variant="inline" />
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<div className="p-3"><EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" /></div> <EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" variant="inline" />
) : rows.map(r => { ) : rows.map(r => {
const open = expanded.has(r.date); const open = expanded.has(r.date);
const isAbnormal = Math.abs(r.chainPct) >= 0.3; const isAbnormal = Math.abs(r.chainPct) >= 0.3;
+10 -16
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { ChevronLeft, ChevronRight, Loader2, ReceiptText, RefreshCw, X } from 'lucide-react'; import { ChevronLeft, ChevronRight, ReceiptText, X } from 'lucide-react';
import Blur from '../../components/Blur'; import Blur from '../../components/Blur';
import { EmptyState, ErrorState, LoadingState } from '../../components/ui/surface';
import { fetchHydrogenOrders } from './api'; import { fetchHydrogenOrders } from './api';
import type { CustomerType, HydrogenOrderResponse } from './types'; import type { CustomerType, HydrogenOrderResponse } from './types';
@@ -81,23 +82,16 @@ export default function HydrogenOrders({
) : null} ) : null}
{error ? ( {error ? (
<div className="flex flex-col items-center px-4 py-8 text-center"> <ErrorState
<div className="text-xs font-black text-rose-700"></div> title="订单数据暂不可用"
<div className="mt-1 text-[10px] font-bold text-rose-500">{error}</div> message={error}
<button onRetry={() => setRetryKey(key => key + 1)}
type="button" variant="inline"
onClick={() => setRetryKey(key => key + 1)} />
className="mt-3 inline-flex h-8 items-center gap-1.5 rounded-lg border border-rose-100 bg-rose-50 px-3 text-[10px] font-black text-rose-700"
>
<RefreshCw size={12} />
</button>
</div>
) : loading && !data ? ( ) : loading && !data ? (
<div className="flex items-center justify-center gap-2 py-10 text-xs font-bold text-slate-400"> <LoadingState label="正在加载加氢订单" variant="inline" />
<Loader2 size={15} className="animate-spin" />
</div>
) : data?.items.length === 0 ? ( ) : data?.items.length === 0 ? (
<div className="py-10 text-center text-xs font-bold text-slate-400"></div> <EmptyState title="当前筛选下无加氢订单" description="请切换日期、车辆归属或实体范围" variant="inline" />
) : data ? ( ) : data ? (
<> <>
<div className="hidden overflow-x-auto md:block"> <div className="hidden overflow-x-auto md:block">