fix(dashboard): 正确展示 SoH 预测不可用
This commit is contained in:
+139
-110
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user