feat(ui): 增强电池看板状态表达

This commit is contained in:
2026-05-12 00:07:15 +08:00
parent 5d9aa660d8
commit 38943f239f
5 changed files with 255 additions and 122 deletions
+129 -122
View File
@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { createFileRoute, Link } from '@tanstack/react-router'
import { AlertTriangle, ArrowRight, Database, ShieldCheck, Tags, TrendingDown } from 'lucide-react'
import {
Area,
CartesianGrid,
@@ -13,6 +14,7 @@ import {
} from 'recharts'
import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent'
import { orpc } from '@/client/orpc'
import { Badge, Card, SectionTitle } from '@/components/ui'
import type { DashboardSnapshot, DeviceStatus } from '@/domain/battery'
import { BATTERY_LIST_SORT, DEVICE_STATUS } from '@/domain/battery'
@@ -43,7 +45,9 @@ function buildChartData(soh: DashboardSnapshot['soh']) {
}
}
for (let i = 1; i < soh.forecast.length; i++) {
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({
@@ -57,16 +61,16 @@ function buildChartData(soh: DashboardSnapshot['soh']) {
return chartData
}
const statusColorMap: Record<DeviceStatus, string> = {
[DEVICE_STATUS.HEALTHY]: 'text-emerald-400',
[DEVICE_STATUS.WATCH]: 'text-amber-400',
[DEVICE_STATUS.WARNING]: 'text-red-400',
const statusVariantMap: Record<DeviceStatus, 'success' | 'warning' | 'danger'> = {
[DEVICE_STATUS.HEALTHY]: 'success',
[DEVICE_STATUS.WATCH]: 'warning',
[DEVICE_STATUS.WARNING]: 'danger',
}
const severityColorMap: Record<DashboardSnapshot['events'][number]['severity'], string> = {
: 'text-red-400',
: 'text-amber-400',
: 'text-zinc-400',
const severityVariantMap: Record<DashboardSnapshot['events'][number]['severity'], 'danger' | 'warning' | 'muted'> = {
: 'danger',
: 'warning',
: 'muted',
}
function formatChartTooltip(value: ValueType | undefined, name: NameType | undefined) {
@@ -145,30 +149,20 @@ function Dashboard() {
{/* Header */}
<header className="animate-fade-up mb-12 flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl">
<div className="mb-4 inline-flex items-center gap-2 rounded-md border border-white/10 bg-white/5 px-2.5 py-1 text-xs font-medium text-teal-400">
(v2.4)
</div>
<Badge variant="info" className="mb-4">
<Database className="size-3.5" /> MySQL + AI API
</Badge>
<h1 className="text-4xl font-light tracking-tight text-white sm:text-5xl">SoH </h1>
</div>
<div className="flex flex-col items-end gap-3 text-right">
<div className="flex items-center gap-6 text-sm">
<div>
<span className="text-[#71717A]"> (MAE)</span>
<span className="ml-2 font-medium tabular-nums text-teal-400">1.2%</span>
</div>
<div className="h-4 w-px bg-white/10" />
<div>
<span className="text-[#71717A]"></span>
<span className="ml-2 font-medium tabular-nums text-teal-400">94.5%</span>
</div>
</div>
<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="text-xs text-teal-400 hover:text-teal-300"
className="inline-flex items-center gap-1 text-xs text-teal-400 hover:text-teal-300"
>
<ArrowRight className="size-3" />
</Link>
</div>
</header>
@@ -192,7 +186,7 @@ function Dashboard() {
<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 ? 'AI预测不可用' : '基线健康'}
{avgSoh === null ? 'AI预测不可用' : '预测已返回'}
</span>
<span className="text-[#71717A]">|</span>
<span className="text-[#A1A1AA]"> {totalDevices} </span>
@@ -244,20 +238,23 @@ function Dashboard() {
{/* SoH Trend Chart */}
<section className="animate-fade-up delay-300 mb-12">
<article className="rounded-2xl border border-white/[0.06] bg-white/[0.03] p-8">
<Card className="p-8">
<header className="mb-8 flex flex-wrap items-end justify-between gap-4">
<div>
<h3 className="text-xl font-medium text-white">SoH 90 </h3>
<p className="mt-1 text-sm text-[#A1A1AA]"> 12 3 </p>
</div>
<SectionTitle
icon={<TrendingDown className="size-4" />}
title="SoH 预测点位"
description="图表只展示 AI 预测 API 返回的当前、30 天、90 天聚合点;没有真实 SoH 历史时不补假趋势。"
/>
<div className="flex items-center gap-6 text-sm text-[#A1A1AA]">
<span className="inline-flex items-center gap-2">
<span className="h-2 w-4 rounded-full bg-teal-400" />
</span>
{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" />
API
</span>
</div>
</header>
@@ -361,7 +358,7 @@ function Dashboard() {
</div>
)}
</div>
</article>
</Card>
</section>
{/* Two-column grid */}
@@ -370,7 +367,9 @@ function Dashboard() {
<div className="space-y-8">
{/* Risk Distribution */}
<div>
<h3 className="mb-6 text-lg font-medium text-white"></h3>
<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">
@@ -428,7 +427,7 @@ function Dashboard() {
{/* Regional Performance */}
<div>
<h3 className="mb-6 text-lg font-medium text-white"></h3>
<h3 className="mb-6 text-lg font-medium text-white"></h3>
<div className="space-y-4">
{batchPerformance.length > 0 ? (
batchPerformance.map((item) => (
@@ -458,7 +457,9 @@ function Dashboard() {
<div className="space-y-8">
{/* Event Timeline */}
<div>
<h3 className="mb-6 text-lg font-medium text-white"></h3>
<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) => (
@@ -470,7 +471,7 @@ function Dashboard() {
/>
<div className="mb-1 flex items-center gap-3">
<span className="text-xs font-medium tabular-nums text-[#71717A]">{event.time}</span>
<span className={`text-xs ${severityColorMap[event.severity]}`}>{event.severity}</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>
@@ -484,19 +485,18 @@ function Dashboard() {
{/* Risk Factor Frequency */}
<div>
<h3 className="mb-6 text-lg font-medium text-white"></h3>
<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) => (
<div
key={item.factor}
className="flex items-center gap-2 rounded-md border border-white/5 bg-white/[0.02] px-3 py-1.5 text-sm"
>
<span className="text-[#A1A1AA]">{item.factor}</span>
<span className="rounded bg-white/10 px-1.5 py-0.5 text-xs tabular-nums text-white">
<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>
</div>
</Badge>
))
) : (
<div className="text-sm text-[#71717A]"></div>
@@ -514,81 +514,88 @@ function Dashboard() {
<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/60/90 </p>
<p className="mt-1 text-sm text-[#A1A1AA]">
API 30 90 SoH
</p>
</div>
</div>
<div className="overflow-x-auto rounded-xl border border-white/[0.06] bg-white/[0.02]">
<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-[#A1A1AA]">
<th className="px-6 py-4 font-medium"></th>
<th className="px-6 py-4 font-medium"></th>
<th className="px-6 py-4 font-medium"> SoH</th>
<th className="px-6 py-4 font-medium">30</th>
<th className="px-6 py-4 font-medium">90</th>
<th className="px-6 py-4 font-medium"></th>
<th className="px-6 py-4 font-medium"></th>
<th className="px-6 py-4 font-medium"></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">
<span className={statusColorMap[unit.status]}>{unit.status}</span>
</td>
<td className="px-6 py-4">
<div className="flex flex-wrap gap-1.5">
{unit.riskFactors.length > 0 ? (
unit.riskFactors.map((factor) => (
<span key={factor} className="text-[#A1A1AA]">
{factor}
{factor !== unit.riskFactors[unit.riskFactors.length - 1] && '、'}
</span>
))
) : (
<span className="text-[#71717A]">-</span>
)}
</div>
</td>
</tr>
))
) : (
<tr>
<td colSpan={8} className="px-6 py-8 text-center text-[#71717A]">
</td>
<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"> SoH</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>
)}
</tbody>
</table>
</div>
</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>
</section>
{/* Bottom Row */}
@@ -622,7 +629,7 @@ function Dashboard() {
{/* Firmware Comparison */}
<div>
<h3 className="mb-6 text-lg font-medium text-white"></h3>
<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 ? (