feat(ui): 使用 Recharts 并改为客户端 API 请求

This commit is contained in:
2026-05-11 21:58:23 +08:00
parent 749697634a
commit 158d4007e4
4 changed files with 227 additions and 149 deletions
+21 -5
View File
@@ -1,13 +1,10 @@
import { useSuspenseQuery } from '@tanstack/react-query'
import { useQuery } from '@tanstack/react-query'
import { createFileRoute, Link } from '@tanstack/react-router'
import { orpc } from '@/client/orpc'
import type { BatteryInfo } from '@/domain/battery'
export const Route = createFileRoute('/batteries')({
component: BatteriesPage,
loader: async ({ context }) => {
await context.queryClient.ensureQueryData(orpc.battery.batteries.queryOptions({ input: {} }))
},
errorComponent: ({ error }) => (
<main className="flex min-h-screen items-center justify-center bg-[#09090B]">
<div className="text-center">
@@ -69,13 +66,32 @@ function DeviceCard({ item }: { item: BatteryInfo }) {
}
function BatteriesPage() {
const { data } = useSuspenseQuery(
const { data, error, isPending } = useQuery(
orpc.battery.batteries.queryOptions({
input: {},
refetchInterval: 30_000,
}),
)
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-zinc-500">{error.message}</p>
</div>
</main>
)
}
if (isPending || !data) {
return (
<main className="flex min-h-screen items-center justify-center bg-[#09090B]">
<p className="text-zinc-500"></p>
</main>
)
}
return (
<main className="min-h-screen bg-[#09090B] px-6 py-8 text-zinc-100">
<header className="mx-auto max-w-7xl">
+130 -144
View File
@@ -1,13 +1,22 @@
import { useSuspenseQuery } from '@tanstack/react-query'
import { useQuery } from '@tanstack/react-query'
import { createFileRoute, Link } from '@tanstack/react-router'
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 type { DashboardSnapshot, DeviceStatus } from '@/domain/battery'
export const Route = createFileRoute('/')({
component: Dashboard,
loader: async ({ context }) => {
await context.queryClient.ensureQueryData(orpc.battery.dashboard.queryOptions())
},
errorComponent: ({ error }) => (
<main className="flex min-h-screen items-center justify-center bg-[#09090B]">
<div className="text-center">
@@ -59,149 +68,36 @@ const severityColorMap: Record<DashboardSnapshot['events'][number]['severity'],
: 'text-zinc-400',
}
function SimpleChart({ data }: { data: ReturnType<typeof buildChartData> }) {
if (data.length === 0) {
return <div className="flex h-full items-center justify-center text-[#71717A]"></div>
}
function formatChartTooltip(value: ValueType | undefined, name: NameType | undefined) {
const numericValue = typeof value === 'number' ? value : Number(value)
const minVal = Math.floor(Math.min(...data.map((d) => Math.min(d.history ?? 100, d.forecast ?? 100)))) - 2
const maxVal = Math.ceil(Math.max(...data.map((d) => Math.max(d.history ?? 0, d.forecast ?? 0)))) + 2
const range = maxVal - minVal
const width = 1000
const height = 280
const padding = { top: 20, right: 20, bottom: 30, left: 40 }
const innerWidth = width - padding.left - padding.right
const innerHeight = height - padding.top - padding.bottom
const getX = (index: number) => padding.left + (index / (data.length - 1)) * innerWidth
const getY = (val: number) => padding.top + innerHeight - ((val - minVal) / range) * innerHeight
const historyPoints = data
.map((d, i) => (d.history !== undefined ? `${getX(i)},${getY(d.history)}` : null))
.filter(Boolean)
.join(' ')
const forecastPoints = data
.map((d, i) => (d.forecast !== undefined ? `${getX(i)},${getY(d.forecast)}` : null))
.filter(Boolean)
.join(' ')
const lastHistoryIndex = data.findLastIndex((d) => d.history !== undefined)
const historyArea = historyPoints
? `${historyPoints} ${getX(lastHistoryIndex)},${padding.top + innerHeight} ${getX(0)},${padding.top + innerHeight}`
: ''
const forecastStartIndex = data.findIndex((d) => d.forecast !== undefined)
const forecastArea = forecastPoints
? `${forecastPoints} ${getX(data.length - 1)},${padding.top + innerHeight} ${getX(forecastStartIndex)},${padding.top + innerHeight}`
: ''
return (
<svg
viewBox={`0 0 ${width} ${height}`}
className="w-full h-full overflow-visible"
role="img"
aria-label="SoH Trend Chart"
>
<title>SoH Trend Chart</title>
<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>
{/* Grid */}
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
const y = padding.top + innerHeight * ratio
const val = maxVal - range * ratio
return (
<g key={ratio}>
<line x1={padding.left} y1={y} x2={width - padding.right} y2={y} stroke="#ffffff" strokeOpacity={0.05} />
<text x={padding.left - 10} y={y + 4} fill="#71717A" fontSize="11" textAnchor="end">
{val.toFixed(0)}%
</text>
</g>
)
})}
{/* 85% Reference Line */}
{85 >= minVal && 85 <= maxVal && (
<g>
<line
x1={padding.left}
y1={getY(85)}
x2={width - padding.right}
y2={getY(85)}
stroke="#F87171"
strokeOpacity={0.4}
strokeDasharray="4 4"
/>
<text x={width - padding.right + 10} y={getY(85) + 4} fill="#F87171" fontSize="11">
85% 线
</text>
</g>
)}
{/* X Axis */}
{data.map((d, i) => (
<text key={d.month} x={getX(i)} y={height - 5} fill="#71717A" fontSize="11" textAnchor="middle">
{d.month}
</text>
))}
{/* Areas */}
{historyArea && <polygon points={historyArea} fill="url(#historyFill)" />}
{forecastArea && <polygon points={forecastArea} fill="url(#forecastFill)" />}
{/* Lines */}
{historyPoints && <polyline points={historyPoints} fill="none" stroke="#2DD4BF" strokeWidth="2.5" />}
{forecastPoints && (
<polyline points={forecastPoints} fill="none" stroke="#818CF8" strokeWidth="2.5" strokeDasharray="4 4" />
)}
{/* Dots */}
{data.map((d, i) => {
const dots = []
if (d.history !== undefined) {
dots.push(
<circle
key={`h-${d.month}`}
cx={getX(i)}
cy={getY(d.history)}
r="3"
fill="#09090B"
stroke="#2DD4BF"
strokeWidth="2"
/>,
)
}
if (d.forecast !== undefined) {
dots.push(
<circle
key={`f-${d.month}`}
cx={getX(i)}
cy={getY(d.forecast)}
r="3"
fill="#09090B"
stroke="#818CF8"
strokeWidth="2"
/>,
)
}
return dots
})}
</svg>
)
return [
`${Number.isFinite(numericValue) ? numericValue.toFixed(1) : (value ?? '-')}%`,
name === 'history' ? '历史观测' : '模型预测',
]
}
function Dashboard() {
const { data } = useSuspenseQuery(orpc.battery.dashboard.queryOptions())
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 {
@@ -345,7 +241,97 @@ function Dashboard() {
</header>
<div className="w-full h-[320px]">
<SimpleChart data={chartData} />
<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>
</article>
</section>