fix(dashboard): 正确展示 SoH 预测不可用

This commit is contained in:
2026-05-11 23:38:37 +08:00
parent 99d9cd1e1d
commit a131bb845b
+139 -110
View File
@@ -14,6 +14,7 @@ import {
import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent' import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent'
import { orpc } from '@/client/orpc' import { orpc } from '@/client/orpc'
import type { DashboardSnapshot, DeviceStatus } from '@/domain/battery' import type { DashboardSnapshot, DeviceStatus } from '@/domain/battery'
import { BATTERY_LIST_SORT, DEVICE_STATUS } from '@/domain/battery'
export const Route = createFileRoute('/')({ export const Route = createFileRoute('/')({
component: Dashboard, component: Dashboard,
@@ -57,9 +58,9 @@ function buildChartData(soh: DashboardSnapshot['soh']) {
} }
const statusColorMap: Record<DeviceStatus, string> = { const statusColorMap: Record<DeviceStatus, string> = {
: 'text-emerald-400', [DEVICE_STATUS.HEALTHY]: 'text-emerald-400',
: 'text-amber-400', [DEVICE_STATUS.WATCH]: 'text-amber-400',
: 'text-red-400', [DEVICE_STATUS.WARNING]: 'text-red-400',
} }
const severityColorMap: Record<DashboardSnapshot['events'][number]['severity'], string> = { const severityColorMap: Record<DashboardSnapshot['events'][number]['severity'], string> = {
@@ -77,6 +78,23 @@ function formatChartTooltip(value: ValueType | undefined, name: NameType | undef
] ]
} }
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() { function Dashboard() {
const { data, error, isPending } = useQuery(orpc.battery.dashboard.queryOptions()) const { data, error, isPending } = useQuery(orpc.battery.dashboard.queryOptions())
@@ -147,7 +165,7 @@ function Dashboard() {
<p className="text-xs tabular-nums text-[#71717A]">: {updatedAt}</p> <p className="text-xs tabular-nums text-[#71717A]">: {updatedAt}</p>
<Link <Link
to="/batteries" to="/batteries"
search={{ pageSize: 50, sort: 'createdAtDesc', cursors: [] }} search={{ pageSize: 50, sort: BATTERY_LIST_SORT.CREATED_AT_DESC, cursors: [] }}
className="text-xs text-teal-400 hover:text-teal-300" className="text-xs text-teal-400 hover:text-teal-300"
> >
@@ -168,13 +186,13 @@ function Dashboard() {
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-teal-500/50 to-transparent" /> <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]"> SoH</p> <p className="text-sm font-medium text-[#A1A1AA]"> SoH</p>
<div className="mt-4 flex items-baseline gap-2"> <div className="mt-4 flex items-baseline gap-2">
<h2 className="text-6xl font-light tabular-nums text-white">{avgSoh.toFixed(1)}</h2> <h2 className="text-6xl font-light tabular-nums text-white">{formatPercent(avgSoh)}</h2>
<span className="text-2xl text-[#71717A]">%</span> {avgSoh !== null && <span className="text-2xl text-[#71717A]">%</span>}
</div> </div>
<div className="mt-6 flex items-center gap-3 text-sm"> <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="inline-flex items-center gap-1.5 text-emerald-400">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" /> <span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
线 {avgSoh === null ? 'AI预测不可用' : '基线健康'}
</span> </span>
<span className="text-[#71717A]">|</span> <span className="text-[#71717A]">|</span>
<span className="text-[#A1A1AA]"> {totalDevices} </span> <span className="text-[#A1A1AA]"> {totalDevices} </span>
@@ -185,24 +203,24 @@ function Dashboard() {
<article className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-6 transition-colors hover:border-white/10"> <article 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> <p className="text-sm text-[#A1A1AA]">30 </p>
<div className="mt-3 flex items-baseline gap-1"> <div className="mt-3 flex items-baseline gap-1">
<h2 className="text-4xl font-light tabular-nums text-white">{avgSoh30d.toFixed(1)}</h2> <h2 className="text-4xl font-light tabular-nums text-white">{formatPercent(avgSoh30d)}</h2>
<span className="text-lg text-[#71717A]">%</span> {avgSoh30d !== null && <span className="text-lg text-[#71717A]">%</span>}
</div> </div>
<div className="mt-4 flex items-center gap-1.5 text-sm"> <div className="mt-4 flex items-center gap-1.5 text-sm">
<span className="text-red-400"></span> <span className="text-red-400"></span>
<span className="tabular-nums text-[#A1A1AA]">{(avgSoh - avgSoh30d).toFixed(1)}% </span> <span className="tabular-nums text-[#A1A1AA]">{formatDelta(avgSoh, avgSoh30d)}</span>
</div> </div>
</article> </article>
<article className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-6 transition-colors hover:border-white/10"> <article 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> <p className="text-sm text-[#A1A1AA]">90 </p>
<div className="mt-3 flex items-baseline gap-1"> <div className="mt-3 flex items-baseline gap-1">
<h2 className="text-4xl font-light tabular-nums text-white">{avgSoh90d.toFixed(1)}</h2> <h2 className="text-4xl font-light tabular-nums text-white">{formatPercent(avgSoh90d)}</h2>
<span className="text-lg text-[#71717A]">%</span> {avgSoh90d !== null && <span className="text-lg text-[#71717A]">%</span>}
</div> </div>
<div className="mt-4 flex items-center gap-1.5 text-sm"> <div className="mt-4 flex items-center gap-1.5 text-sm">
<span className="text-red-400"></span> <span className="text-red-400"></span>
<span className="tabular-nums text-[#A1A1AA]">{(avgSoh - avgSoh90d).toFixed(1)}% </span> <span className="tabular-nums text-[#A1A1AA]">{formatDelta(avgSoh, avgSoh90d)}</span>
</div> </div>
</article> </article>
@@ -245,97 +263,103 @@ function Dashboard() {
</header> </header>
<div className="w-full h-[320px]"> <div className="w-full h-[320px]">
<ResponsiveContainer width="100%" height="100%"> {chartData.length > 0 ? (
<ComposedChart data={chartData} margin={{ top: 24, right: 24, bottom: 8, left: 0 }}> <ResponsiveContainer width="100%" height="100%">
<defs> <ComposedChart data={chartData} margin={{ top: 24, right: 24, bottom: 8, left: 0 }}>
<linearGradient id="historyFill" x1="0" y1="0" x2="0" y2="1"> <defs>
<stop offset="0%" stopColor="#2DD4BF" stopOpacity={0.15} /> <linearGradient id="historyFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="100%" stopColor="#2DD4BF" stopOpacity={0} /> <stop offset="0%" stopColor="#2DD4BF" stopOpacity={0.15} />
</linearGradient> <stop offset="100%" stopColor="#2DD4BF" stopOpacity={0} />
<linearGradient id="forecastFill" x1="0" y1="0" x2="0" y2="1"> </linearGradient>
<stop offset="0%" stopColor="#818CF8" stopOpacity={0.15} /> <linearGradient id="forecastFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="100%" stopColor="#818CF8" stopOpacity={0} /> <stop offset="0%" stopColor="#818CF8" stopOpacity={0.15} />
</linearGradient> <stop offset="100%" stopColor="#818CF8" stopOpacity={0} />
</defs> </linearGradient>
<CartesianGrid stroke="#ffffff" strokeOpacity={0.05} vertical={false} /> </defs>
<XAxis dataKey="month" tick={{ fill: '#71717A', fontSize: 11 }} axisLine={false} tickLine={false} /> <CartesianGrid stroke="#ffffff" strokeOpacity={0.05} vertical={false} />
<YAxis <XAxis dataKey="month" tick={{ fill: '#71717A', fontSize: 11 }} axisLine={false} tickLine={false} />
domain={[(min: number) => Math.floor(min) - 2, (max: number) => Math.ceil(max) + 2]} <YAxis
tick={{ fill: '#71717A', fontSize: 11 }} domain={[(min: number) => Math.floor(min) - 2, (max: number) => Math.ceil(max) + 2]}
axisLine={false} tick={{ fill: '#71717A', fontSize: 11 }}
tickLine={false} axisLine={false}
tickFormatter={(v: number) => `${v}%`} tickLine={false}
width={48} tickFormatter={(v: number) => `${v}%`}
/> width={48}
<Tooltip />
contentStyle={{ <Tooltip
backgroundColor: '#18181B', contentStyle={{
border: '1px solid rgba(255,255,255,0.1)', backgroundColor: '#18181B',
borderRadius: 8, border: '1px solid rgba(255,255,255,0.1)',
fontSize: 13, borderRadius: 8,
color: '#F4F4F5', fontSize: 13,
}} color: '#F4F4F5',
itemStyle={{ color: '#A1A1AA' }} }}
formatter={formatChartTooltip} itemStyle={{ color: '#A1A1AA' }}
labelStyle={{ color: '#71717A', marginBottom: 4 }} formatter={formatChartTooltip}
/> labelStyle={{ color: '#71717A', marginBottom: 4 }}
<ReferenceLine />
y={85} <ReferenceLine
stroke="#F87171" y={85}
strokeOpacity={0.4} stroke="#F87171"
strokeDasharray="4 4" strokeOpacity={0.4}
label={{ strokeDasharray="4 4"
value: '85% 预警线', label={{
fill: '#F87171', value: '85% 预警线',
fontSize: 11, fill: '#F87171',
position: 'right', fontSize: 11,
}} position: 'right',
/> }}
<Area />
type="monotone" <Area
dataKey="history" type="monotone"
fill="url(#historyFill)" dataKey="history"
stroke="none" fill="url(#historyFill)"
connectNulls={false} stroke="none"
tooltipType="none" connectNulls={false}
/> tooltipType="none"
<Area />
type="monotone" <Area
dataKey="forecast" type="monotone"
fill="url(#forecastFill)" dataKey="forecast"
stroke="none" fill="url(#forecastFill)"
connectNulls={false} stroke="none"
tooltipType="none" connectNulls={false}
/> tooltipType="none"
<Line />
type="monotone" <Line
dataKey="history" type="monotone"
stroke="#2DD4BF" dataKey="history"
strokeWidth={2.5} stroke="#2DD4BF"
dot={{ strokeWidth={2.5}
fill: '#09090B', dot={{
stroke: '#2DD4BF', fill: '#09090B',
strokeWidth: 2, stroke: '#2DD4BF',
r: 3, strokeWidth: 2,
}} r: 3,
connectNulls={false} }}
/> connectNulls={false}
<Line />
type="monotone" <Line
dataKey="forecast" type="monotone"
stroke="#818CF8" dataKey="forecast"
strokeWidth={2.5} stroke="#818CF8"
strokeDasharray="4 4" strokeWidth={2.5}
dot={{ strokeDasharray="4 4"
fill: '#09090B', dot={{
stroke: '#818CF8', fill: '#09090B',
strokeWidth: 2, stroke: '#818CF8',
r: 3, strokeWidth: 2,
}} r: 3,
connectNulls={false} }}
/> connectNulls={false}
</ComposedChart> />
</ResponsiveContainer> </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]">
AI SoH
</div>
)}
</div> </div>
</article> </article>
</section> </section>
@@ -412,10 +436,15 @@ function Dashboard() {
<span className="w-20 text-sm text-[#A1A1AA]">{item.batch}</span> <span className="w-20 text-sm text-[#A1A1AA]">{item.batch}</span>
<div className="flex-1"> <div className="flex-1">
<div className="h-1.5 overflow-hidden rounded-full bg-white/5"> <div className="h-1.5 overflow-hidden rounded-full bg-white/5">
<div className="h-full rounded-full bg-white/20" style={{ width: `${item.avgSoh}%` }} /> <div
className="h-full rounded-full bg-white/20"
style={{ width: widthPercent(item.avgSoh) }}
/>
</div> </div>
</div> </div>
<span className="w-12 text-right text-sm tabular-nums text-white">{item.avgSoh.toFixed(1)}%</span> <span className="w-20 text-right text-sm tabular-nums text-white">
{formatPercentWithUnit(item.avgSoh)}
</span>
</div> </div>
)) ))
) : ( ) : (
@@ -511,9 +540,9 @@ function Dashboard() {
<tr key={unit.id} className="transition-colors hover:bg-white/[0.04]"> <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 font-medium text-white">{unit.id}</td>
<td className="px-6 py-4 text-[#A1A1AA]">{unit.batch}</td> <td className="px-6 py-4 text-[#A1A1AA]">{unit.batch}</td>
<td className="px-6 py-4 tabular-nums text-white">{unit.soh.toFixed(1)}%</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]">{unit.soh30d.toFixed(1)}%</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]">{unit.soh90d.toFixed(1)}%</td> <td className="px-6 py-4 tabular-nums text-[#A1A1AA]">{formatPercentWithUnit(unit.soh90d)}</td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-1.5 w-12 overflow-hidden rounded-full bg-white/5"> <div className="h-1.5 w-12 overflow-hidden rounded-full bg-white/5">
@@ -604,7 +633,7 @@ function Dashboard() {
<div className="text-xs text-[#71717A]">{item.count} </div> <div className="text-xs text-[#71717A]">{item.count} </div>
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="text-sm tabular-nums text-white">{item.avgSoh.toFixed(1)}%</div> <div className="text-sm tabular-nums text-white">{formatPercentWithUnit(item.avgSoh)}</div>
<div className="text-xs text-[#71717A]"> SoH</div> <div className="text-xs text-[#71717A]"> SoH</div>
</div> </div>
</div> </div>