658 lines
29 KiB
TypeScript
658 lines
29 KiB
TypeScript
import { useQuery } from '@tanstack/react-query'
|
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
|
import { Activity, AlertTriangle, ArrowRight, ShieldCheck, Tags, TrendingDown } from 'lucide-react'
|
|
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 { MotionCardArticle, MotionCardDiv, MotionHeader, MotionSection } from '@/components/motion'
|
|
import { Badge, Card, SectionTitle } from '@/components/ui'
|
|
import type { DashboardSnapshot, DeviceStatus } from '@/domain/battery'
|
|
import { BATTERY_LIST_SORT, DEVICE_STATUS } from '@/domain/battery'
|
|
|
|
export const Route = createFileRoute('/')({
|
|
component: Dashboard,
|
|
errorComponent: ({ error }) => (
|
|
<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>
|
|
),
|
|
})
|
|
|
|
function buildChartData(soh: DashboardSnapshot['soh']) {
|
|
const chartData: { month: string; history?: number; forecast?: number }[] = soh.history.map((h) => ({
|
|
month: h.month,
|
|
history: h.value,
|
|
forecast: undefined,
|
|
}))
|
|
|
|
if (chartData.length > 0 && soh.forecast.length > 0) {
|
|
// Overlap: last history point is also first forecast point
|
|
const last = chartData[chartData.length - 1]
|
|
if (last) {
|
|
last.forecast = soh.forecast[0]?.value
|
|
}
|
|
}
|
|
|
|
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({
|
|
month: f.month,
|
|
history: undefined,
|
|
forecast: f.value,
|
|
})
|
|
}
|
|
}
|
|
|
|
return chartData
|
|
}
|
|
|
|
const statusVariantMap: Record<DeviceStatus, 'success' | 'warning' | 'danger'> = {
|
|
[DEVICE_STATUS.HEALTHY]: 'success',
|
|
[DEVICE_STATUS.WATCH]: 'warning',
|
|
[DEVICE_STATUS.WARNING]: 'danger',
|
|
}
|
|
|
|
const severityVariantMap: Record<DashboardSnapshot['events'][number]['severity'], 'danger' | 'warning' | 'muted'> = {
|
|
高: 'danger',
|
|
中: 'warning',
|
|
低: 'muted',
|
|
}
|
|
|
|
function formatChartTooltip(value: ValueType | undefined, name: NameType | undefined) {
|
|
const numericValue = typeof value === 'number' ? value : Number(value)
|
|
|
|
return [
|
|
`${Number.isFinite(numericValue) ? numericValue.toFixed(1) : (value ?? '-')}%`,
|
|
name === 'history' ? '历史观测' : '趋势预测',
|
|
]
|
|
}
|
|
|
|
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() {
|
|
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 {
|
|
totalDevices,
|
|
avgSoh,
|
|
avgSoh30d,
|
|
avgSoh90d,
|
|
warningCount,
|
|
watchCount,
|
|
healthyCount,
|
|
batchPerformance,
|
|
riskFactorCounts,
|
|
firmwareHealth,
|
|
updatedAt,
|
|
executiveSummary,
|
|
} = summary
|
|
const chartData = buildChartData(soh)
|
|
|
|
return (
|
|
<main className="min-h-screen w-full bg-[#09090B] font-sans text-[#F4F4F5]">
|
|
{/* Background gradient */}
|
|
<div className="pointer-events-none fixed inset-0 z-0 flex justify-center">
|
|
<div className="h-[800px] w-[1200px] -translate-y-1/2 rounded-full bg-teal-900/10 blur-[120px]" />
|
|
</div>
|
|
|
|
<div className="relative z-10 mx-auto max-w-[1400px] px-6 pb-24 pt-12 lg:px-12">
|
|
{/* Header */}
|
|
<MotionHeader className="mb-12 flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
|
|
<div className="max-w-3xl">
|
|
<Badge variant="info" className="mb-4">
|
|
<Activity className="size-3.5" /> 实时数据与健康预测
|
|
</Badge>
|
|
<h1 className="text-4xl font-light tracking-tight text-white sm:text-5xl">电池健康与风险洞察</h1>
|
|
</div>
|
|
<div className="flex flex-col items-end gap-3 text-right">
|
|
<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="inline-flex items-center gap-1 text-xs text-teal-400 hover:text-teal-300"
|
|
>
|
|
设备电池实时状态 <ArrowRight className="size-3" />
|
|
</Link>
|
|
</div>
|
|
</MotionHeader>
|
|
|
|
{/* Executive Summary */}
|
|
<MotionSection delay={0.1} className="mb-12 rounded-xl border border-teal-900/30 bg-teal-950/10 p-6">
|
|
<h2 className="mb-3 text-sm font-medium text-teal-400">执行摘要</h2>
|
|
<p className="text-base leading-relaxed text-[#A1A1AA]">{executiveSummary}</p>
|
|
</MotionSection>
|
|
|
|
{/* Primary KPI Row */}
|
|
<MotionSection delay={0.2} className="mb-12 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-5">
|
|
{/* Hero KPI */}
|
|
<MotionCardArticle className="relative overflow-hidden rounded-2xl border border-white/10 bg-white/[0.03] p-8 lg:col-span-2">
|
|
<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]">当前平均健康度</p>
|
|
<div className="mt-4 flex items-baseline gap-2">
|
|
<h2 className="text-6xl font-light tabular-nums text-white">{formatPercent(avgSoh)}</h2>
|
|
{avgSoh !== null && <span className="text-2xl text-[#71717A]">%</span>}
|
|
</div>
|
|
<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 ? '健康预测暂不可用' : '预测已返回'}
|
|
</span>
|
|
<span className="text-[#71717A]">|</span>
|
|
<span className="text-[#A1A1AA]">共 {totalDevices} 台设备</span>
|
|
</div>
|
|
</MotionCardArticle>
|
|
|
|
{/* Regular KPIs */}
|
|
<MotionCardArticle 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>
|
|
<div className="mt-3 flex items-baseline gap-1">
|
|
<h2 className="text-4xl font-light tabular-nums text-white">{formatPercent(avgSoh30d)}</h2>
|
|
{avgSoh30d !== null && <span className="text-lg text-[#71717A]">%</span>}
|
|
</div>
|
|
<div className="mt-4 flex items-center gap-1.5 text-sm">
|
|
<span className="text-red-400">↘</span>
|
|
<span className="tabular-nums text-[#A1A1AA]">{formatDelta(avgSoh, avgSoh30d)}</span>
|
|
</div>
|
|
</MotionCardArticle>
|
|
|
|
<MotionCardArticle 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>
|
|
<div className="mt-3 flex items-baseline gap-1">
|
|
<h2 className="text-4xl font-light tabular-nums text-white">{formatPercent(avgSoh90d)}</h2>
|
|
{avgSoh90d !== null && <span className="text-lg text-[#71717A]">%</span>}
|
|
</div>
|
|
<div className="mt-4 flex items-center gap-1.5 text-sm">
|
|
<span className="text-red-400">↘</span>
|
|
<span className="tabular-nums text-[#A1A1AA]">{formatDelta(avgSoh, avgSoh90d)}</span>
|
|
</div>
|
|
</MotionCardArticle>
|
|
|
|
<MotionCardArticle 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]">高风险设备</p>
|
|
<div className="mt-3 flex items-baseline gap-1">
|
|
<h2 className="text-4xl font-light tabular-nums text-white">{warningCount}</h2>
|
|
<span className="text-lg text-[#71717A]">台</span>
|
|
</div>
|
|
<div className="mt-4 flex items-center gap-1.5 text-sm">
|
|
<span className="text-amber-400">↗</span>
|
|
<span className="tabular-nums text-[#A1A1AA]">
|
|
占比 {totalDevices > 0 ? ((warningCount / totalDevices) * 100).toFixed(1) : 0}%
|
|
</span>
|
|
</div>
|
|
</MotionCardArticle>
|
|
</MotionSection>
|
|
|
|
{/* Divider */}
|
|
<hr className="my-12 border-white/5" />
|
|
|
|
{/* Health trend chart */}
|
|
<MotionSection delay={0.3} className="mb-12">
|
|
<Card className="p-8">
|
|
<header className="mb-8 flex flex-wrap items-end justify-between gap-4">
|
|
<SectionTitle
|
|
icon={<TrendingDown className="size-4" />}
|
|
title="健康趋势预测"
|
|
description="展示当前健康度与未来 30/90 天趋势;数据不足时保持空态,避免误导判断。"
|
|
/>
|
|
<div className="flex items-center gap-6 text-sm text-[#A1A1AA]">
|
|
{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" />
|
|
预测趋势
|
|
</span>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="w-full h-[320px]">
|
|
{chartData.length > 0 ? (
|
|
<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 className="flex h-full items-center justify-center rounded-xl border border-dashed border-white/10 bg-white/[0.02] text-sm text-[#71717A]">
|
|
暂无可用的健康趋势数据。
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
</MotionSection>
|
|
|
|
{/* Two-column grid */}
|
|
<MotionSection delay={0.4} className="mb-12 grid grid-cols-1 gap-8 lg:grid-cols-2">
|
|
{/* Left Column */}
|
|
<div className="space-y-8">
|
|
{/* Risk Distribution */}
|
|
<div>
|
|
<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">
|
|
<span className="text-[#A1A1AA]">健康 (> 90%)</span>
|
|
<span className="tabular-nums text-white">
|
|
{healthyCount} 台{' '}
|
|
<span className="text-[#71717A]">
|
|
/ {totalDevices > 0 ? ((healthyCount / totalDevices) * 100).toFixed(1) : 0}%
|
|
</span>
|
|
</span>
|
|
</div>
|
|
<div className="h-1.5 overflow-hidden rounded-full bg-white/5">
|
|
<div
|
|
className="h-full rounded-full bg-emerald-400"
|
|
style={{ width: `${totalDevices > 0 ? (healthyCount / totalDevices) * 100 : 0}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="mb-2 flex justify-between text-sm">
|
|
<span className="text-[#A1A1AA]">关注 (85% - 90%)</span>
|
|
<span className="tabular-nums text-white">
|
|
{watchCount} 台{' '}
|
|
<span className="text-[#71717A]">
|
|
/ {totalDevices > 0 ? ((watchCount / totalDevices) * 100).toFixed(1) : 0}%
|
|
</span>
|
|
</span>
|
|
</div>
|
|
<div className="h-1.5 overflow-hidden rounded-full bg-white/5">
|
|
<div
|
|
className="h-full rounded-full bg-amber-400"
|
|
style={{ width: `${totalDevices > 0 ? (watchCount / totalDevices) * 100 : 0}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="mb-2 flex justify-between text-sm">
|
|
<span className="text-[#A1A1AA]">预警 (≤ 85%)</span>
|
|
<span className="tabular-nums text-white">
|
|
{warningCount} 台{' '}
|
|
<span className="text-[#71717A]">
|
|
/ {totalDevices > 0 ? ((warningCount / totalDevices) * 100).toFixed(1) : 0}%
|
|
</span>
|
|
</span>
|
|
</div>
|
|
<div className="h-1.5 overflow-hidden rounded-full bg-white/5">
|
|
<div
|
|
className="h-full rounded-full bg-red-400"
|
|
style={{ width: `${totalDevices > 0 ? (warningCount / totalDevices) * 100 : 0}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Regional Performance */}
|
|
<div>
|
|
<h3 className="mb-6 text-lg font-medium text-white">设备型号健康度概览</h3>
|
|
<div className="space-y-4">
|
|
{batchPerformance.length > 0 ? (
|
|
batchPerformance.map((item) => (
|
|
<div key={item.batch} className="flex items-center gap-4">
|
|
<span className="w-20 text-sm text-[#A1A1AA]">{item.batch}</span>
|
|
<div className="flex-1">
|
|
<div className="h-1.5 overflow-hidden rounded-full bg-white/5">
|
|
<div
|
|
className="h-full rounded-full bg-white/20"
|
|
style={{ width: widthPercent(item.avgSoh) }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<span className="w-20 text-right text-sm tabular-nums text-white">
|
|
{formatPercentWithUnit(item.avgSoh)}
|
|
</span>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="text-sm text-[#71717A]">暂无数据</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Column */}
|
|
<div className="space-y-8">
|
|
{/* Event Timeline */}
|
|
<div>
|
|
<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) => (
|
|
<div key={event.time + event.title} className="mb-6 last:mb-0">
|
|
<div
|
|
className={`absolute -left-[4px] mt-1.5 h-2 w-2 rounded-full ${
|
|
event.severity === '高' ? 'bg-red-400' : 'bg-amber-400'
|
|
}`}
|
|
/>
|
|
<div className="mb-1 flex items-center gap-3">
|
|
<span className="text-xs font-medium tabular-nums text-[#71717A]">{event.time}</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>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="text-sm text-[#71717A]">暂无数据</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Risk Factor Frequency */}
|
|
<div>
|
|
<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) => (
|
|
<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>
|
|
</Badge>
|
|
))
|
|
) : (
|
|
<div className="text-sm text-[#71717A]">暂无数据</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</MotionSection>
|
|
|
|
{/* Divider */}
|
|
<hr className="my-12 border-white/5" />
|
|
|
|
{/* Device Table */}
|
|
<MotionSection delay={0.5} className="mb-12">
|
|
<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/90 天趋势。</p>
|
|
</div>
|
|
</div>
|
|
<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">当前健康度</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>
|
|
</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>
|
|
</MotionSection>
|
|
|
|
{/* Bottom Row */}
|
|
<MotionSection delay={0.5} className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
|
{/* Strategy Cards */}
|
|
<div className="lg:col-span-2">
|
|
<h3 className="mb-6 text-lg font-medium text-white">行动建议</h3>
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
{strategies.length > 0 ? (
|
|
strategies.map((item, index) => (
|
|
<MotionCardDiv
|
|
key={item.name}
|
|
className="relative overflow-hidden rounded-xl border border-white/[0.06] bg-white/[0.02] p-5"
|
|
>
|
|
<div
|
|
className={`absolute bottom-0 left-0 top-0 w-1 ${index === 0 ? 'bg-red-400' : index === 1 ? 'bg-amber-400' : 'bg-teal-400'}`}
|
|
/>
|
|
<h4 className="font-medium text-white">{item.name}</h4>
|
|
<p className="mt-2 text-sm text-[#A1A1AA]">{item.impact}</p>
|
|
<div className="mt-4 flex flex-wrap gap-4 text-xs text-[#71717A]">
|
|
<span>范围: {item.scope}</span>
|
|
<span>时效: {item.eta}</span>
|
|
</div>
|
|
</MotionCardDiv>
|
|
))
|
|
) : (
|
|
<div className="text-sm text-[#71717A] col-span-2">暂无策略建议</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Firmware Comparison */}
|
|
<div>
|
|
<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 ? (
|
|
firmwareHealth.map((item) => (
|
|
<div key={item.firmware} className="flex items-center justify-between">
|
|
<div>
|
|
<div className="text-sm font-medium text-white">{item.firmware}</div>
|
|
<div className="text-xs text-[#71717A]">{item.count} 台设备</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="text-sm tabular-nums text-white">{formatPercentWithUnit(item.avgSoh)}</div>
|
|
<div className="text-xs text-[#71717A]">平均健康度</div>
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="text-sm text-[#71717A]">暂无数据</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</MotionSection>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|