feat: focus ETC metric drilldowns

This commit is contained in:
kkfluous
2026-08-07 17:07:29 +08:00
parent 64571d58c3
commit 003a0b580c
2 changed files with 55 additions and 5 deletions
+5 -1
View File
@@ -89,7 +89,11 @@ export default function ETCDetails({
}; };
return ( return (
<section className="overflow-hidden rounded-lg border border-slate-100 bg-white shadow-sm"> <section
id="etc-details"
tabIndex={-1}
className="scroll-mt-3 overflow-hidden rounded-lg border border-slate-100 bg-white shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
>
<header className="flex flex-col gap-3 border-b border-slate-100 px-3 py-3 md:px-4"> <header className="flex flex-col gap-3 border-b border-slate-100 px-3 py-3 md:px-4">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
+50 -4
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { CircleCheck, CircleX, Clock3, Database, ReceiptText, RefreshCw, Route, Truck } from 'lucide-react'; import { CircleCheck, CircleX, Clock3, Database, ReceiptText, RefreshCw, Route, Truck } from 'lucide-react';
import { fetchEtcOverview } from './api'; import { fetchEtcOverview } from './api';
import type { EtcOverviewResponse } from './types'; import type { EtcOverviewResponse } from './types';
@@ -23,6 +24,31 @@ function syncStatusLabel(status: EtcOverviewResponse['integration']['lastSyncSta
return '状态未知'; return '状态未知';
} }
function DrilldownMetricButton({
label,
title,
onClick,
children,
}: {
label: string;
title: string;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
aria-controls="etc-details"
aria-label={label}
className="min-w-0 rounded-2xl text-left outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
title={title}
>
{children}
</button>
);
}
export default function ETCView() { export default function ETCView() {
const [data, setData] = useState<EtcOverviewResponse | null>(null); const [data, setData] = useState<EtcOverviewResponse | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -47,6 +73,18 @@ export default function ETCView() {
); );
}, [commitDetailContext, detailContext]); }, [commitDetailContext, detailContext]);
const openDetailView = useCallback((next: EtcDetailView) => {
selectDetailView(next);
window.requestAnimationFrame(() => {
const details = document.getElementById('etc-details');
details?.focus({ preventScroll: true });
details?.scrollIntoView({
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
block: 'start',
});
});
}, [selectDetailView]);
useEffect(() => { useEffect(() => {
const handlePopState = () => setDetailContext(parseEtcDrillContext(window.location.search)); const handlePopState = () => setDetailContext(parseEtcDrillContext(window.location.search));
window.addEventListener('popstate', handlePopState); window.addEventListener('popstate', handlePopState);
@@ -104,10 +142,18 @@ export default function ETCView() {
</SurfaceCard> </SurfaceCard>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4"> <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<button type="button" onClick={() => selectDetailView('records')} className="text-left" title="查看通行记录"><MetricTile icon={ReceiptText} label="ETC 总费用" value={fmtMoney(kpi.totalAmount)} helper={`通行费 ${fmtMoney(kpi.tollAmount)} · 服务费 ${fmtMoney(kpi.serviceFee)}`} /></button> <DrilldownMetricButton label={`查看 ETC 通行记录,总费用 ${fmtMoney(kpi.totalAmount)}`} title="查看通行记录" onClick={() => openDetailView('records')}>
<button type="button" onClick={() => selectDetailView('records')} className="text-left" title="查看通行记录"><MetricTile icon={Route} label="通行次数" value={kpi.passageCount} unit="次" helper={kpi.latestTransactionTime ? `最新 ${kpi.latestTransactionTime}` : '暂无通行记录'} tone="emerald" /></button> <MetricTile icon={ReceiptText} label="ETC 总费用" value={fmtMoney(kpi.totalAmount)} helper={`通行费 ${fmtMoney(kpi.tollAmount)} · 服务费 ${fmtMoney(kpi.serviceFee)}`} />
<button type="button" onClick={() => selectDetailView('records')} className="text-left" title="查看通行记录"><MetricTile icon={Truck} label="通行车辆" value={kpi.vehicleCount} unit="辆" helper="按车牌去重" tone="amber" /></button> </DrilldownMetricButton>
<button type="button" onClick={() => selectDetailView('bills')} className="text-left" title="查看结算账单"><MetricTile icon={Database} label="账单应收" value={fmtMoney(kpi.receivableAmount)} helper={`${kpi.billCount} 账单 · 已收 ${fmtMoney(kpi.paidAmount)} · ${collectionRate.toFixed(1)}%`} tone="slate" /></button> <DrilldownMetricButton label={`查看 ETC 通行记录,共 ${kpi.passageCount}`} title="查看通行记录" onClick={() => openDetailView('records')}>
<MetricTile icon={Route} label="通行次数" value={kpi.passageCount} unit="次" helper={kpi.latestTransactionTime ? `最新 ${kpi.latestTransactionTime}` : '暂无通行记录'} tone="emerald" />
</DrilldownMetricButton>
<DrilldownMetricButton label={`查看 ETC 通行记录,共 ${kpi.vehicleCount} 辆车`} title="查看通行记录" onClick={() => openDetailView('records')}>
<MetricTile icon={Truck} label="通行车辆" value={kpi.vehicleCount} unit="辆" helper="按车牌去重" tone="amber" />
</DrilldownMetricButton>
<DrilldownMetricButton label={`查看 ETC 结算账单,应收 ${fmtMoney(kpi.receivableAmount)}`} title="查看结算账单" onClick={() => openDetailView('bills')}>
<MetricTile icon={Database} label="账单应收" value={fmtMoney(kpi.receivableAmount)} helper={`${kpi.billCount} 账单 · 已收 ${fmtMoney(kpi.paidAmount)} · ${collectionRate.toFixed(1)}%`} tone="slate" />
</DrilldownMetricButton>
</div> </div>
<div className="grid gap-3 md:grid-cols-3"> <div className="grid gap-3 md:grid-cols-3">