refactor: expose ETC integration readiness
This commit is contained in:
+101
-68
@@ -1,79 +1,112 @@
|
||||
import { motion } from 'motion/react';
|
||||
import { Construction, Hammer } from 'lucide-react';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CircleCheck, CircleX, Clock3, Database, ReceiptText, RefreshCw, Route, Truck } from 'lucide-react';
|
||||
import { fetchEtcOverview } from './api';
|
||||
import type { EtcOverviewResponse } from './types';
|
||||
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||
|
||||
const ETC_HINTS = [
|
||||
'ETC 通行费数据正在与发卡方系统打通…',
|
||||
'工人 GG 正在搭脚手架,敬请期待 ~',
|
||||
'马上能看到每月通行费明细啦',
|
||||
'想看哪个维度的 ETC?反馈一下嘛',
|
||||
'上线时机:等数据接通的那一天',
|
||||
];
|
||||
function fmtMoney(value: number): string {
|
||||
return `¥${value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
function syncStatusLabel(status: EtcOverviewResponse['integration']['lastSyncStatus']): string {
|
||||
if (status === 'success') return '同步成功';
|
||||
if (status === 'failed') return '同步失败';
|
||||
if (status === 'partial') return '部分成功';
|
||||
if (status === 'never') return '尚未同步';
|
||||
return '状态未知';
|
||||
}
|
||||
|
||||
export default function ETCView() {
|
||||
const [data, setData] = useState<EtcOverviewResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const load = async (force = false) => {
|
||||
if (force) setRefreshing(true);
|
||||
setError(null);
|
||||
try {
|
||||
setData(await fetchEtcOverview(force));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
if (error && !data) return <ErrorState message={error} />;
|
||||
if (!data) return <LoadingState label="正在检查 ETC 接入状态" />;
|
||||
|
||||
const { integration, kpi } = data;
|
||||
const hasData = kpi.passageCount > 0;
|
||||
const collectionRate = kpi.receivableAmount > 0 ? kpi.paidAmount / kpi.receivableAmount * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
|
||||
>
|
||||
<div className="relative w-20 h-20 mb-4">
|
||||
<motion.div
|
||||
animate={{ rotate: [0, -8, 8, -4, 4, 0] }}
|
||||
transition={{ duration: 2.4, repeat: Infinity, ease: 'easeInOut' }}
|
||||
className="absolute inset-0 rounded-3xl bg-gradient-to-br from-amber-50 to-orange-50 flex items-center justify-center"
|
||||
>
|
||||
<Construction size={36} className="text-amber-500" strokeWidth={2.2} />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
animate={{ rotate: [0, 18, -10, 0], y: [0, -2, 1, 0] }}
|
||||
transition={{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }}
|
||||
className="absolute -top-1 -right-1 w-9 h-9 rounded-2xl bg-white border border-amber-100 shadow-sm flex items-center justify-center"
|
||||
>
|
||||
<Hammer size={16} className="text-amber-500" strokeWidth={2.2} />
|
||||
</motion.div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<SurfaceCard className="flex items-center gap-3 p-3">
|
||||
<span className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${integration.providerConfigured ? 'bg-emerald-50 text-emerald-600' : 'bg-amber-50 text-amber-600'}`}>
|
||||
{integration.providerConfigured ? <CircleCheck size={18} /> : <CircleX size={18} />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-black text-slate-800">
|
||||
{integration.providerConfigured ? 'ETC 数据提供方已配置' : 'ETC 数据提供方尚未配置'}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-[10px] font-bold text-slate-400">
|
||||
{integration.providerConfigured
|
||||
? `${integration.providerType || '已配置提供方'} · ${syncStatusLabel(integration.lastSyncStatus)}`
|
||||
: '数据库结构已准备,等待配置同步账户并完成首次同步'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void load(true)}
|
||||
disabled={refreshing}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-500 hover:text-blue-600 disabled:opacity-50"
|
||||
aria-label="刷新 ETC 状态"
|
||||
title="刷新 ETC 状态"
|
||||
>
|
||||
<RefreshCw size={15} className={refreshing ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</SurfaceCard>
|
||||
|
||||
<div className="text-base font-black text-slate-800 mb-1.5">ETC 模块建设中</div>
|
||||
<div className="text-[12px] text-slate-500 font-bold leading-relaxed max-w-[280px]">
|
||||
通行费明细、按车按月统计、运营成本拆分
|
||||
<br />
|
||||
这些数据都在路上啦
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricTile icon={ReceiptText} label="ETC 总费用" value={fmtMoney(kpi.totalAmount)} helper={`通行费 ${fmtMoney(kpi.tollAmount)} · 服务费 ${fmtMoney(kpi.serviceFee)}`} />
|
||||
<MetricTile icon={Route} label="通行次数" value={kpi.passageCount} unit="次" helper={kpi.latestTransactionTime ? `最新 ${kpi.latestTransactionTime}` : '暂无通行记录'} tone="emerald" />
|
||||
<MetricTile icon={Truck} label="通行车辆" value={kpi.vehicleCount} unit="辆" helper="按车牌去重" tone="amber" />
|
||||
<MetricTile icon={Database} label="账单应收" value={fmtMoney(kpi.receivableAmount)} helper={`${kpi.billCount} 账单 · 已收 ${fmtMoney(kpi.paidAmount)} · ${collectionRate.toFixed(1)}%`} tone="slate" />
|
||||
</div>
|
||||
|
||||
{/* 简单的里程碑进度感 */}
|
||||
<div className="mt-6 w-full max-w-xs space-y-2">
|
||||
{[
|
||||
{ label: '需求评审', done: true },
|
||||
{ label: '数据对接', done: true },
|
||||
{ label: '页面开发', done: false, current: true },
|
||||
{ label: '正式上线', done: false },
|
||||
].map((m, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.1 + i * 0.08, duration: 0.3 }}
|
||||
className="flex items-center gap-2.5 text-[11px]"
|
||||
>
|
||||
<span className={`w-3 h-3 rounded-full flex-shrink-0 ${
|
||||
m.done ? 'bg-emerald-400'
|
||||
: m.current ? 'bg-amber-400 ring-4 ring-amber-100 animate-pulse'
|
||||
: 'bg-slate-200'
|
||||
}`} />
|
||||
<span className={`font-bold ${m.done ? 'text-slate-500' : m.current ? 'text-amber-600' : 'text-slate-300'}`}>
|
||||
{m.label}
|
||||
</span>
|
||||
{m.done && <span className="text-[10px] text-emerald-500 font-bold ml-auto">已完成</span>}
|
||||
{m.current && <span className="text-[10px] text-amber-500 font-bold ml-auto">进行中</span>}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<SurfaceCard className="p-3">
|
||||
<div className="text-[10px] font-black text-slate-400">提供方配置</div>
|
||||
<div className={`mt-1 text-sm font-black ${integration.providerConfigured ? 'text-emerald-600' : 'text-amber-600'}`}>
|
||||
{integration.providerConfigured ? '已配置' : '未配置'}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
<SurfaceCard className="p-3">
|
||||
<div className="text-[10px] font-black text-slate-400">自动同步</div>
|
||||
<div className={`mt-1 text-sm font-black ${integration.syncEnabled ? 'text-emerald-600' : 'text-slate-500'}`}>
|
||||
{integration.syncEnabled ? '已启用' : '未启用'}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
<SurfaceCard className="p-3">
|
||||
<div className="flex items-center gap-1 text-[10px] font-black text-slate-400"><Clock3 size={11} />最近同步</div>
|
||||
<div className="mt-1 text-sm font-black text-slate-700">{syncStatusLabel(integration.lastSyncStatus)}</div>
|
||||
<div className="mt-1 text-[10px] font-bold text-slate-400">{integration.lastSyncAt || '无同步记录'}</div>
|
||||
</SurfaceCard>
|
||||
</div>
|
||||
|
||||
<RotatingFooterHint hints={ETC_HINTS} />
|
||||
{!hasData && (
|
||||
<EmptyState
|
||||
title="ETC 尚无可统计数据"
|
||||
description="当前通行记录、账单、同步配置和同步日志均为空;完成提供方配置与首次同步后,本页将直接展示真实指标。"
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && <ErrorState message={error} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@ export default function EtcModule() {
|
||||
return (
|
||||
<PageFrame
|
||||
title="ETC 通行费看板"
|
||||
subtitle="规划按车、按月、按线路拆分通行费,让车辆运营成本口径逐步完整。"
|
||||
subtitle="区分通行记录与结算账单口径,展示通行费、服务费、车辆规模及数据同步状态。"
|
||||
icon={Receipt}
|
||||
eyebrow="ETC BI"
|
||||
meta="数据对接中 · 页面能力预留"
|
||||
meta="真实接入状态 · 指标口径已发布"
|
||||
>
|
||||
<ETCView />
|
||||
</PageFrame>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
HydrogenCustomerRow, HydrogenStationFull,
|
||||
ElectricKpi, ElectricDailyRow, ElectricMonthGroup,
|
||||
ElectricChargeOrderResponse,
|
||||
EtcOverviewResponse,
|
||||
CustomerType, DateQuickPick,
|
||||
} from './types';
|
||||
|
||||
@@ -67,3 +68,7 @@ export function fetchElectricOrders(date: string, customer: CustomerType): Promi
|
||||
const q = new URLSearchParams({ date, customer });
|
||||
return fetchJson<ElectricChargeOrderResponse>(`${BASE}/electric/orders?${q.toString()}`);
|
||||
}
|
||||
|
||||
export function fetchEtcOverview(force = false): Promise<EtcOverviewResponse> {
|
||||
return fetchJson<EtcOverviewResponse>(`${BASE}/etc/overview${force ? '?force=1' : ''}`);
|
||||
}
|
||||
|
||||
@@ -124,3 +124,24 @@ export interface ElectricChargeOrderResponse {
|
||||
truncated: boolean;
|
||||
items: ElectricChargeOrder[];
|
||||
}
|
||||
|
||||
export interface EtcOverviewResponse {
|
||||
integration: {
|
||||
providerConfigured: boolean;
|
||||
syncEnabled: boolean;
|
||||
providerType: string | null;
|
||||
lastSyncAt: string | null;
|
||||
lastSyncStatus: 'never' | 'success' | 'failed' | 'partial' | 'unknown';
|
||||
};
|
||||
kpi: {
|
||||
passageCount: number;
|
||||
vehicleCount: number;
|
||||
tollAmount: number;
|
||||
serviceFee: number;
|
||||
totalAmount: number;
|
||||
billCount: number;
|
||||
receivableAmount: number;
|
||||
paidAmount: number;
|
||||
latestTransactionTime: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user