Files
fullstack-starter/src/routes/index.tsx
T

658 lines
29 KiB
TypeScript

import { useQuery } from '@tanstack/react-query'
import { createFileRoute, Link } from '@tanstack/react-router'
import { Activity, AlertTriangle, ArrowRight, ShieldCheck, Tags, TrendingDown } from 'lucide-react'
import {
Area,
CartesianGrid,
ComposedChart,
Line,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent'
import { orpc } from '@/client/orpc'
import { MotionCardArticle, MotionCardDiv, MotionHeader, MotionSection } from '@/components/motion'
import { Badge, Card, SectionTitle } from '@/components/ui'
import type { DashboardSnapshot, DeviceStatus } from '@/domain/battery'
import { BATTERY_LIST_SORT, DEVICE_STATUS } from '@/domain/battery'
export const Route = createFileRoute('/')({
component: Dashboard,
errorComponent: ({ error }) => (
<main className="flex min-h-screen items-center justify-center bg-[#09090B]">
<div className="text-center">
<p className="text-lg text-red-400"></p>
<p className="mt-2 text-sm text-[#71717A]">{error.message}</p>
</div>
</main>
),
})
function buildChartData(soh: DashboardSnapshot['soh']) {
const chartData: { month: string; history?: number; forecast?: number }[] = soh.history.map((h) => ({
month: h.month,
history: h.value,
forecast: undefined,
}))
if (chartData.length > 0 && soh.forecast.length > 0) {
// Overlap: last history point is also first forecast point
const last = chartData[chartData.length - 1]
if (last) {
last.forecast = soh.forecast[0]?.value
}
}
const forecastStart = chartData.length > 0 ? 1 : 0
for (let i = forecastStart; i < soh.forecast.length; i++) {
const f = soh.forecast[i]
if (f) {
chartData.push({
month: f.month,
history: undefined,
forecast: f.value,
})
}
}
return chartData
}
const statusVariantMap: Record<DeviceStatus, 'success' | 'warning' | 'danger'> = {
[DEVICE_STATUS.HEALTHY]: 'success',
[DEVICE_STATUS.WATCH]: 'warning',
[DEVICE_STATUS.WARNING]: 'danger',
}
const severityVariantMap: Record<DashboardSnapshot['events'][number]['severity'], 'danger' | 'warning' | 'muted'> = {
: 'danger',
: 'warning',
: 'muted',
}
function formatChartTooltip(value: ValueType | undefined, name: NameType | undefined) {
const numericValue = typeof value === 'number' ? value : Number(value)
return [
`${Number.isFinite(numericValue) ? numericValue.toFixed(1) : (value ?? '-')}%`,
name === 'history' ? '历史观测' : '趋势预测',
]
}
function formatPercent(value: number | null) {
return value === null ? '—' : value.toFixed(1)
}
function formatPercentWithUnit(value: number | null) {
return value === null ? '预测不可用' : `${value.toFixed(1)}%`
}
function formatDelta(from: number | null, to: number | null) {
if (from === null || to === null) return '预测不可用'
return `${(from - to).toFixed(1)}% 衰减`
}
function widthPercent(value: number | null) {
return `${Math.max(0, Math.min(100, value ?? 0))}%`
}
function Dashboard() {
const { data, error, isPending } = useQuery(orpc.battery.dashboard.queryOptions())
if (error) {
return (
<main className="flex min-h-screen items-center justify-center bg-[#09090B]">
<div className="text-center">
<p className="text-lg text-red-400"></p>
<p className="mt-2 text-sm text-[#71717A]">{error.message}</p>
</div>
</main>
)
}
if (isPending || !data) {
return (
<main className="flex min-h-screen items-center justify-center bg-[#09090B]">
<p className="text-[#71717A]"></p>
</main>
)
}
const { devices, soh, events, strategies, summary } = data
const {
totalDevices,
avgSoh,
avgSoh30d,
avgSoh90d,
warningCount,
watchCount,
healthyCount,
batchPerformance,
riskFactorCounts,
firmwareHealth,
updatedAt,
executiveSummary,
} = summary
const chartData = buildChartData(soh)
return (
<main className="min-h-screen w-full bg-[#09090B] font-sans text-[#F4F4F5]">
{/* Background gradient */}
<div className="pointer-events-none fixed inset-0 z-0 flex justify-center">
<div className="h-[800px] w-[1200px] -translate-y-1/2 rounded-full bg-teal-900/10 blur-[120px]" />
</div>
<div className="relative z-10 mx-auto max-w-[1400px] px-6 pb-24 pt-12 lg:px-12">
{/* Header */}
<MotionHeader className="mb-12 flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl">
<Badge variant="info" className="mb-4">
<Activity className="size-3.5" />
</Badge>
<h1 className="text-4xl font-light tracking-tight text-white sm:text-5xl"></h1>
</div>
<div className="flex flex-col items-end gap-3 text-right">
<Badge variant="muted"></Badge>
<p className="text-xs tabular-nums text-[#71717A]">: {updatedAt}</p>
<Link
to="/batteries"
search={{ pageSize: 50, sort: BATTERY_LIST_SORT.CREATED_AT_DESC, cursors: [] }}
className="inline-flex items-center gap-1 text-xs text-teal-400 hover:text-teal-300"
>
<ArrowRight className="size-3" />
</Link>
</div>
</MotionHeader>
{/* Executive Summary */}
<MotionSection delay={0.1} className="mb-12 rounded-xl border border-teal-900/30 bg-teal-950/10 p-6">
<h2 className="mb-3 text-sm font-medium text-teal-400"></h2>
<p className="text-base leading-relaxed text-[#A1A1AA]">{executiveSummary}</p>
</MotionSection>
{/* Primary KPI Row */}
<MotionSection delay={0.2} className="mb-12 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-5">
{/* Hero KPI */}
<MotionCardArticle className="relative overflow-hidden rounded-2xl border border-white/10 bg-white/[0.03] p-8 lg:col-span-2">
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-teal-500/50 to-transparent" />
<p className="text-sm font-medium text-[#A1A1AA]"></p>
<div className="mt-4 flex items-baseline gap-2">
<h2 className="text-6xl font-light tabular-nums text-white">{formatPercent(avgSoh)}</h2>
{avgSoh !== null && <span className="text-2xl text-[#71717A]">%</span>}
</div>
<div className="mt-6 flex items-center gap-3 text-sm">
<span className="inline-flex items-center gap-1.5 text-emerald-400">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
{avgSoh === null ? '健康预测暂不可用' : '预测已返回'}
</span>
<span className="text-[#71717A]">|</span>
<span className="text-[#A1A1AA]"> {totalDevices} </span>
</div>
</MotionCardArticle>
{/* Regular KPIs */}
<MotionCardArticle className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-6 transition-colors hover:border-white/10">
<p className="text-sm text-[#A1A1AA]">30 </p>
<div className="mt-3 flex items-baseline gap-1">
<h2 className="text-4xl font-light tabular-nums text-white">{formatPercent(avgSoh30d)}</h2>
{avgSoh30d !== null && <span className="text-lg text-[#71717A]">%</span>}
</div>
<div className="mt-4 flex items-center gap-1.5 text-sm">
<span className="text-red-400"></span>
<span className="tabular-nums text-[#A1A1AA]">{formatDelta(avgSoh, avgSoh30d)}</span>
</div>
</MotionCardArticle>
<MotionCardArticle className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-6 transition-colors hover:border-white/10">
<p className="text-sm text-[#A1A1AA]">90 </p>
<div className="mt-3 flex items-baseline gap-1">
<h2 className="text-4xl font-light tabular-nums text-white">{formatPercent(avgSoh90d)}</h2>
{avgSoh90d !== null && <span className="text-lg text-[#71717A]">%</span>}
</div>
<div className="mt-4 flex items-center gap-1.5 text-sm">
<span className="text-red-400"></span>
<span className="tabular-nums text-[#A1A1AA]">{formatDelta(avgSoh, avgSoh90d)}</span>
</div>
</MotionCardArticle>
<MotionCardArticle className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-6 transition-colors hover:border-white/10">
<p className="text-sm text-[#A1A1AA]"></p>
<div className="mt-3 flex items-baseline gap-1">
<h2 className="text-4xl font-light tabular-nums text-white">{warningCount}</h2>
<span className="text-lg text-[#71717A]"></span>
</div>
<div className="mt-4 flex items-center gap-1.5 text-sm">
<span className="text-amber-400"></span>
<span className="tabular-nums text-[#A1A1AA]">
{totalDevices > 0 ? ((warningCount / totalDevices) * 100).toFixed(1) : 0}%
</span>
</div>
</MotionCardArticle>
</MotionSection>
{/* Divider */}
<hr className="my-12 border-white/5" />
{/* Health trend chart */}
<MotionSection delay={0.3} className="mb-12">
<Card className="p-8">
<header className="mb-8 flex flex-wrap items-end justify-between gap-4">
<SectionTitle
icon={<TrendingDown className="size-4" />}
title="健康趋势预测"
description="展示当前健康度与未来 30/90 天趋势;数据不足时保持空态,避免误导判断。"
/>
<div className="flex items-center gap-6 text-sm text-[#A1A1AA]">
{soh.history.length > 0 && (
<span className="inline-flex items-center gap-2">
<span className="h-2 w-4 rounded-full bg-teal-400" />
</span>
)}
<span className="inline-flex items-center gap-2">
<span className="h-2 w-4 rounded-full bg-indigo-400" />
</span>
</div>
</header>
<div className="w-full h-[320px]">
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ top: 24, right: 24, bottom: 8, left: 0 }}>
<defs>
<linearGradient id="historyFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#2DD4BF" stopOpacity={0.15} />
<stop offset="100%" stopColor="#2DD4BF" stopOpacity={0} />
</linearGradient>
<linearGradient id="forecastFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#818CF8" stopOpacity={0.15} />
<stop offset="100%" stopColor="#818CF8" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid stroke="#ffffff" strokeOpacity={0.05} vertical={false} />
<XAxis dataKey="month" tick={{ fill: '#71717A', fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis
domain={[(min: number) => Math.floor(min) - 2, (max: number) => Math.ceil(max) + 2]}
tick={{ fill: '#71717A', fontSize: 11 }}
axisLine={false}
tickLine={false}
tickFormatter={(v: number) => `${v}%`}
width={48}
/>
<Tooltip
contentStyle={{
backgroundColor: '#18181B',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 8,
fontSize: 13,
color: '#F4F4F5',
}}
itemStyle={{ color: '#A1A1AA' }}
formatter={formatChartTooltip}
labelStyle={{ color: '#71717A', marginBottom: 4 }}
/>
<ReferenceLine
y={85}
stroke="#F87171"
strokeOpacity={0.4}
strokeDasharray="4 4"
label={{
value: '85% 预警线',
fill: '#F87171',
fontSize: 11,
position: 'right',
}}
/>
<Area
type="monotone"
dataKey="history"
fill="url(#historyFill)"
stroke="none"
connectNulls={false}
tooltipType="none"
/>
<Area
type="monotone"
dataKey="forecast"
fill="url(#forecastFill)"
stroke="none"
connectNulls={false}
tooltipType="none"
/>
<Line
type="monotone"
dataKey="history"
stroke="#2DD4BF"
strokeWidth={2.5}
dot={{
fill: '#09090B',
stroke: '#2DD4BF',
strokeWidth: 2,
r: 3,
}}
connectNulls={false}
/>
<Line
type="monotone"
dataKey="forecast"
stroke="#818CF8"
strokeWidth={2.5}
strokeDasharray="4 4"
dot={{
fill: '#09090B',
stroke: '#818CF8',
strokeWidth: 2,
r: 3,
}}
connectNulls={false}
/>
</ComposedChart>
</ResponsiveContainer>
) : (
<div className="flex h-full items-center justify-center rounded-xl border border-dashed border-white/10 bg-white/[0.02] text-sm text-[#71717A]">
</div>
)}
</div>
</Card>
</MotionSection>
{/* Two-column grid */}
<MotionSection delay={0.4} className="mb-12 grid grid-cols-1 gap-8 lg:grid-cols-2">
{/* Left Column */}
<div className="space-y-8">
{/* Risk Distribution */}
<div>
<div className="mb-6">
<SectionTitle icon={<ShieldCheck className="size-4" />} title="健康分布" />
</div>
<div className="space-y-5">
<div>
<div className="mb-2 flex justify-between text-sm">
<span className="text-[#A1A1AA]"> (&gt; 90%)</span>
<span className="tabular-nums text-white">
{healthyCount} {' '}
<span className="text-[#71717A]">
/ {totalDevices > 0 ? ((healthyCount / totalDevices) * 100).toFixed(1) : 0}%
</span>
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-white/5">
<div
className="h-full rounded-full bg-emerald-400"
style={{ width: `${totalDevices > 0 ? (healthyCount / totalDevices) * 100 : 0}%` }}
/>
</div>
</div>
<div>
<div className="mb-2 flex justify-between text-sm">
<span className="text-[#A1A1AA]"> (85% - 90%)</span>
<span className="tabular-nums text-white">
{watchCount} {' '}
<span className="text-[#71717A]">
/ {totalDevices > 0 ? ((watchCount / totalDevices) * 100).toFixed(1) : 0}%
</span>
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-white/5">
<div
className="h-full rounded-full bg-amber-400"
style={{ width: `${totalDevices > 0 ? (watchCount / totalDevices) * 100 : 0}%` }}
/>
</div>
</div>
<div>
<div className="mb-2 flex justify-between text-sm">
<span className="text-[#A1A1AA]"> (&le; 85%)</span>
<span className="tabular-nums text-white">
{warningCount} {' '}
<span className="text-[#71717A]">
/ {totalDevices > 0 ? ((warningCount / totalDevices) * 100).toFixed(1) : 0}%
</span>
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-white/5">
<div
className="h-full rounded-full bg-red-400"
style={{ width: `${totalDevices > 0 ? (warningCount / totalDevices) * 100 : 0}%` }}
/>
</div>
</div>
</div>
</div>
{/* Regional Performance */}
<div>
<h3 className="mb-6 text-lg font-medium text-white"></h3>
<div className="space-y-4">
{batchPerformance.length > 0 ? (
batchPerformance.map((item) => (
<div key={item.batch} className="flex items-center gap-4">
<span className="w-20 text-sm text-[#A1A1AA]">{item.batch}</span>
<div className="flex-1">
<div className="h-1.5 overflow-hidden rounded-full bg-white/5">
<div
className="h-full rounded-full bg-white/20"
style={{ width: widthPercent(item.avgSoh) }}
/>
</div>
</div>
<span className="w-20 text-right text-sm tabular-nums text-white">
{formatPercentWithUnit(item.avgSoh)}
</span>
</div>
))
) : (
<div className="text-sm text-[#71717A]"></div>
)}
</div>
</div>
</div>
{/* Right Column */}
<div className="space-y-8">
{/* Event Timeline */}
<div>
<div className="mb-6">
<SectionTitle icon={<AlertTriangle className="size-4" />} title="风险与趋势概览" />
</div>
<div className="relative border-l border-white/10 pl-5">
{events.length > 0 ? (
events.map((event) => (
<div key={event.time + event.title} className="mb-6 last:mb-0">
<div
className={`absolute -left-[4px] mt-1.5 h-2 w-2 rounded-full ${
event.severity === '高' ? 'bg-red-400' : 'bg-amber-400'
}`}
/>
<div className="mb-1 flex items-center gap-3">
<span className="text-xs font-medium tabular-nums text-[#71717A]">{event.time}</span>
<Badge variant={severityVariantMap[event.severity]}>{event.severity}</Badge>
</div>
<h4 className="text-sm font-medium text-[#F4F4F5]">{event.title}</h4>
<p className="mt-1 text-sm leading-relaxed text-[#A1A1AA]">{event.detail}</p>
</div>
))
) : (
<div className="text-sm text-[#71717A]"></div>
)}
</div>
</div>
{/* Risk Factor Frequency */}
<div>
<div className="mb-6">
<SectionTitle icon={<Tags className="size-4" />} title="主要风险因子分布" />
</div>
<div className="flex flex-wrap gap-2">
{riskFactorCounts.length > 0 ? (
riskFactorCounts.map((item) => (
<Badge key={item.factor} variant={item.factor.includes('不可用') ? 'warning' : 'default'}>
{item.factor}
<span className="rounded-full bg-white/10 px-1.5 py-0.5 tabular-nums text-white">
{item.count}
</span>
</Badge>
))
) : (
<div className="text-sm text-[#71717A]"></div>
)}
</div>
</div>
</div>
</MotionSection>
{/* Divider */}
<hr className="my-12 border-white/5" />
{/* Device Table */}
<MotionSection delay={0.5} className="mb-12">
<div className="mb-6 flex items-end justify-between">
<div>
<h3 className="text-xl font-medium text-white"></h3>
<p className="mt-1 text-sm text-[#A1A1AA]"> 30/90 </p>
</div>
</div>
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full min-w-[1000px] border-collapse text-left text-sm">
<thead>
<tr className="border-b border-white/10 bg-white/[0.02] text-zinc-400">
<th className="px-6 py-4 font-medium whitespace-nowrap"></th>
<th className="px-6 py-4 font-medium whitespace-nowrap"></th>
<th className="px-6 py-4 font-medium whitespace-nowrap"></th>
<th className="px-6 py-4 font-medium whitespace-nowrap">30</th>
<th className="px-6 py-4 font-medium whitespace-nowrap">90</th>
<th className="px-6 py-4 font-medium whitespace-nowrap"></th>
<th className="px-6 py-4 font-medium whitespace-nowrap"></th>
<th className="px-6 py-4 font-medium whitespace-nowrap"></th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{devices.length > 0 ? (
devices
.slice()
.sort((a, b) => b.riskScore - a.riskScore)
.map((unit) => (
<tr key={unit.id} className="transition-colors hover:bg-white/[0.04]">
<td className="px-6 py-4 font-medium text-white">{unit.id}</td>
<td className="px-6 py-4 text-[#A1A1AA]">{unit.batch}</td>
<td className="px-6 py-4 tabular-nums text-white">{formatPercentWithUnit(unit.soh)}</td>
<td className="px-6 py-4 tabular-nums text-[#A1A1AA]">
{formatPercentWithUnit(unit.soh30d)}
</td>
<td className="px-6 py-4 tabular-nums text-[#A1A1AA]">
{formatPercentWithUnit(unit.soh90d)}
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<div className="h-1.5 w-12 overflow-hidden rounded-full bg-white/5">
<div
className={`h-full rounded-full ${
unit.riskScore >= 75
? 'bg-red-400'
: unit.riskScore >= 45
? 'bg-amber-400'
: 'bg-emerald-400'
}`}
style={{ width: `${unit.riskScore}%` }}
/>
</div>
<span className="tabular-nums text-white">{unit.riskScore}</span>
</div>
</td>
<td className="px-6 py-4">
<Badge variant={statusVariantMap[unit.status]}>{unit.status}</Badge>
</td>
<td className="px-6 py-4">
<div className="flex flex-wrap gap-1.5">
{unit.riskFactors.length > 0 ? (
unit.riskFactors.map((factor) => (
<Badge key={factor} variant={factor.includes('不可用') ? 'warning' : 'default'}>
{factor}
</Badge>
))
) : (
<span className="text-[#71717A]">-</span>
)}
</div>
</td>
</tr>
))
) : (
<tr>
<td colSpan={8} className="px-6 py-8 text-center text-[#71717A]">
</td>
</tr>
)}
</tbody>
</table>
</div>
</Card>
</MotionSection>
{/* Bottom Row */}
<MotionSection delay={0.5} className="grid grid-cols-1 gap-8 lg:grid-cols-3">
{/* Strategy Cards */}
<div className="lg:col-span-2">
<h3 className="mb-6 text-lg font-medium text-white"></h3>
<div className="grid gap-4 sm:grid-cols-2">
{strategies.length > 0 ? (
strategies.map((item, index) => (
<MotionCardDiv
key={item.name}
className="relative overflow-hidden rounded-xl border border-white/[0.06] bg-white/[0.02] p-5"
>
<div
className={`absolute bottom-0 left-0 top-0 w-1 ${index === 0 ? 'bg-red-400' : index === 1 ? 'bg-amber-400' : 'bg-teal-400'}`}
/>
<h4 className="font-medium text-white">{item.name}</h4>
<p className="mt-2 text-sm text-[#A1A1AA]">{item.impact}</p>
<div className="mt-4 flex flex-wrap gap-4 text-xs text-[#71717A]">
<span>: {item.scope}</span>
<span>: {item.eta}</span>
</div>
</MotionCardDiv>
))
) : (
<div className="text-sm text-[#71717A] col-span-2"></div>
)}
</div>
</div>
{/* Firmware Comparison */}
<div>
<h3 className="mb-6 text-lg font-medium text-white"></h3>
<div className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-5">
<div className="space-y-4">
{firmwareHealth.length > 0 ? (
firmwareHealth.map((item) => (
<div key={item.firmware} className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-white">{item.firmware}</div>
<div className="text-xs text-[#71717A]">{item.count} </div>
</div>
<div className="text-right">
<div className="text-sm tabular-nums text-white">{formatPercentWithUnit(item.avgSoh)}</div>
<div className="text-xs text-[#71717A]"></div>
</div>
</div>
))
) : (
<div className="text-sm text-[#71717A]"></div>
)}
</div>
</div>
</div>
</MotionSection>
</div>
</main>
)
}