feat(ui): 重建 SoH 看板首页
This commit is contained in:
+616
-71
@@ -1,87 +1,632 @@
|
|||||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
|
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { orpc } from '@/client/orpc'
|
import { orpc } from '@/client/orpc'
|
||||||
import { useInvalidateTodos } from '@/client/queries/todo'
|
import type { DashboardSnapshot, DeviceStatus } from '@/domain/battery'
|
||||||
import { TodoForm } from '@/components/TodoForm'
|
|
||||||
import { TodoItem } from '@/components/TodoItem'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/')({
|
export const Route = createFileRoute('/')({
|
||||||
component: Todos,
|
component: Dashboard,
|
||||||
loader: async ({ context }) => {
|
loader: async ({ context }) => {
|
||||||
await context.queryClient.ensureQueryData(orpc.todo.list.queryOptions())
|
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">
|
||||||
|
<p className="text-lg text-red-400">数据加载失败</p>
|
||||||
|
<p className="mt-2 text-sm text-[#71717A]">{error.message}</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
function Todos() {
|
function buildChartData(soh: DashboardSnapshot['soh']) {
|
||||||
const listQuery = useSuspenseQuery(orpc.todo.list.queryOptions())
|
const chartData: { month: string; history?: number; forecast?: number }[] = soh.history.map((h) => ({
|
||||||
const invalidateTodos = useInvalidateTodos()
|
month: h.month,
|
||||||
|
history: h.value,
|
||||||
|
forecast: undefined,
|
||||||
|
}))
|
||||||
|
|
||||||
const createMutation = useMutation(orpc.todo.create.mutationOptions({ onSuccess: invalidateTodos }))
|
if (chartData.length > 0 && soh.forecast.length > 0) {
|
||||||
const updateMutation = useMutation(orpc.todo.update.mutationOptions({ onSuccess: invalidateTodos }))
|
// Overlap: last history point is also first forecast point
|
||||||
const deleteMutation = useMutation(orpc.todo.remove.mutationOptions({ onSuccess: invalidateTodos }))
|
const last = chartData[chartData.length - 1]
|
||||||
|
if (last) {
|
||||||
|
last.forecast = soh.forecast[0]?.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const todos = listQuery.data
|
for (let i = 1; i < soh.forecast.length; i++) {
|
||||||
const completedCount = todos.filter((todo) => todo.completed).length
|
const f = soh.forecast[i]
|
||||||
const totalCount = todos.length
|
if (f) {
|
||||||
const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0
|
chartData.push({
|
||||||
|
month: f.month,
|
||||||
|
history: undefined,
|
||||||
|
forecast: f.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chartData
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColorMap: Record<DeviceStatus, string> = {
|
||||||
|
健康: 'text-emerald-400',
|
||||||
|
关注: 'text-amber-400',
|
||||||
|
预警: 'text-red-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
const severityColorMap: Record<DashboardSnapshot['events'][number]['severity'], string> = {
|
||||||
|
高: 'text-red-400',
|
||||||
|
中: 'text-amber-400',
|
||||||
|
低: '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>
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 py-12 px-4 sm:px-6 font-sans">
|
<svg
|
||||||
<div className="max-w-2xl mx-auto space-y-8">
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
<div className="flex items-end justify-between">
|
className="w-full h-full overflow-visible"
|
||||||
<div>
|
role="img"
|
||||||
<h1 className="text-3xl font-bold text-slate-900 tracking-tight">我的待办</h1>
|
aria-label="SoH Trend Chart"
|
||||||
<p className="text-slate-500 mt-1">保持专注,逐个击破</p>
|
>
|
||||||
</div>
|
<title>SoH Trend Chart</title>
|
||||||
<div className="text-right">
|
<defs>
|
||||||
<div className="text-2xl font-semibold text-slate-900">
|
<linearGradient id="historyFill" x1="0" y1="0" x2="0" y2="1">
|
||||||
{completedCount}
|
<stop offset="0%" stopColor="#2DD4BF" stopOpacity={0.15} />
|
||||||
<span className="text-slate-400 text-lg">/{totalCount}</span>
|
<stop offset="100%" stopColor="#2DD4BF" stopOpacity={0} />
|
||||||
</div>
|
</linearGradient>
|
||||||
<div className="text-xs font-medium text-slate-400 uppercase tracking-wider">已完成</div>
|
<linearGradient id="forecastFill" x1="0" y1="0" x2="0" y2="1">
|
||||||
</div>
|
<stop offset="0%" stopColor="#818CF8" stopOpacity={0.15} />
|
||||||
</div>
|
<stop offset="100%" stopColor="#818CF8" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
<TodoForm onSubmit={(title) => createMutation.mutate({ title })} isPending={createMutation.isPending} />
|
{/* 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>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
{totalCount > 0 && (
|
{/* 85% Reference Line */}
|
||||||
<div className="h-1.5 w-full bg-slate-200 rounded-full overflow-hidden">
|
{85 >= minVal && 85 <= maxVal && (
|
||||||
<div
|
<g>
|
||||||
className="h-full bg-indigo-500 transition-all duration-500 ease-out rounded-full"
|
<line
|
||||||
style={{ width: `${progress}%` }}
|
x1={padding.left}
|
||||||
/>
|
y1={getY(85)}
|
||||||
</div>
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-3">
|
{/* X Axis */}
|
||||||
{todos.length === 0 ? (
|
{data.map((d, i) => (
|
||||||
<div className="py-20 text-center">
|
<text key={d.month} x={getX(i)} y={height - 5} fill="#71717A" fontSize="11" textAnchor="middle">
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-slate-100 mb-4">
|
{d.month}
|
||||||
<svg
|
</text>
|
||||||
className="w-8 h-8 text-slate-400"
|
))}
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
{/* Areas */}
|
||||||
stroke="currentColor"
|
{historyArea && <polygon points={historyArea} fill="url(#historyFill)" />}
|
||||||
aria-hidden="true"
|
{forecastArea && <polygon points={forecastArea} fill="url(#forecastFill)" />}
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
{/* Lines */}
|
||||||
</svg>
|
{historyPoints && <polyline points={historyPoints} fill="none" stroke="#2DD4BF" strokeWidth="2.5" />}
|
||||||
</div>
|
{forecastPoints && (
|
||||||
<p className="text-slate-500 text-lg font-medium">没有待办事项</p>
|
<polyline points={forecastPoints} fill="none" stroke="#818CF8" strokeWidth="2.5" strokeDasharray="4 4" />
|
||||||
<p className="text-slate-400 text-sm mt-1">输入上方内容添加您的第一个任务</p>
|
)}
|
||||||
</div>
|
|
||||||
) : (
|
{/* Dots */}
|
||||||
todos.map((todo) => (
|
{data.map((d, i) => {
|
||||||
<TodoItem
|
const dots = []
|
||||||
key={todo.id}
|
if (d.history !== undefined) {
|
||||||
todo={todo}
|
dots.push(
|
||||||
onToggle={(id, completed) => updateMutation.mutate({ id, data: { completed: !completed } })}
|
<circle
|
||||||
onDelete={(id) => deleteMutation.mutate({ id })}
|
key={`h-${d.month}`}
|
||||||
/>
|
cx={getX(i)}
|
||||||
))
|
cy={getY(d.history)}
|
||||||
)}
|
r="3"
|
||||||
</div>
|
fill="#09090B"
|
||||||
</div>
|
stroke="#2DD4BF"
|
||||||
</div>
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Dashboard() {
|
||||||
|
const { data } = useSuspenseQuery(orpc.battery.dashboard.queryOptions())
|
||||||
|
|
||||||
|
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 */}
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<p className="text-xs tabular-nums text-[#71717A]">数据更新时间: {updatedAt}</p>
|
||||||
|
<Link to="/batteries" className="text-xs text-teal-400 hover:text-teal-300">
|
||||||
|
设备电池实时状态 →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Executive Summary */}
|
||||||
|
<section className="animate-fade-up delay-100 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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Primary KPI Row */}
|
||||||
|
<section className="animate-fade-up delay-200 mb-12 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||||
|
{/* Hero KPI */}
|
||||||
|
<article 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]">当前平均 SoH</p>
|
||||||
|
<div className="mt-4 flex items-baseline gap-2">
|
||||||
|
<h2 className="text-6xl font-light tabular-nums text-white">{avgSoh.toFixed(1)}</h2>
|
||||||
|
<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" />
|
||||||
|
基线健康
|
||||||
|
</span>
|
||||||
|
<span className="text-[#71717A]">|</span>
|
||||||
|
<span className="text-[#A1A1AA]">共 {totalDevices} 台设备</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{/* Regular KPIs */}
|
||||||
|
<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>
|
||||||
|
<div className="mt-3 flex items-baseline gap-1">
|
||||||
|
<h2 className="text-4xl font-light tabular-nums text-white">{avgSoh30d.toFixed(1)}</h2>
|
||||||
|
<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]">{(avgSoh - avgSoh30d).toFixed(1)}% 衰减</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<div className="mt-3 flex items-baseline gap-1">
|
||||||
|
<h2 className="text-4xl font-light tabular-nums text-white">{avgSoh90d.toFixed(1)}</h2>
|
||||||
|
<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]">{(avgSoh - avgSoh90d).toFixed(1)}% 衰减</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<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]">高风险设备</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>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<hr className="my-12 border-white/5" />
|
||||||
|
|
||||||
|
{/* 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">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<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]">
|
||||||
|
<SimpleChart data={chartData} />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Two-column grid */}
|
||||||
|
<section className="animate-fade-up delay-400 mb-12 grid grid-cols-1 gap-8 lg:grid-cols-2">
|
||||||
|
{/* Left Column */}
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Risk Distribution */}
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-6 text-lg font-medium text-white">风险分层与结构</h3>
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex justify-between text-sm">
|
||||||
|
<span className="text-[#A1A1AA]">健康 (SoH > 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% < SoH ≤ 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]">预警 (SoH ≤ 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: `${item.avgSoh}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="w-12 text-right text-sm tabular-nums text-white">{item.avgSoh.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-[#71717A]">暂无数据</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column */}
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Event Timeline */}
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-6 text-lg font-medium text-white">异常特征时间轴</h3>
|
||||||
|
<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>
|
||||||
|
<span className={`text-xs ${severityColorMap[event.severity]}`}>{event.severity}风险</span>
|
||||||
|
</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>
|
||||||
|
<h3 className="mb-6 text-lg font-medium text-white">主要风险因子分布</h3>
|
||||||
|
<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">
|
||||||
|
{item.count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-[#71717A]">暂无数据</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<hr className="my-12 border-white/5" />
|
||||||
|
|
||||||
|
{/* Device Table */}
|
||||||
|
<section className="animate-fade-up delay-500 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/60/90 天衰减预测</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">{unit.soh.toFixed(1)}%</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]">{unit.soh90d.toFixed(1)}%</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>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Bottom Row */}
|
||||||
|
<section className="animate-fade-up delay-500 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) => (
|
||||||
|
<div
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<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">{item.avgSoh.toFixed(1)}%</div>
|
||||||
|
<div className="text-xs text-[#71717A]">平均 SoH</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-[#71717A]">暂无数据</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user