feat(ui): 使用 Recharts 并改为客户端 API 请求
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user