feat(ui): 增强电池看板状态表达
This commit is contained in:
@@ -23,6 +23,7 @@
|
|||||||
"citty": "^0.2.2",
|
"citty": "^0.2.2",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"lru-cache": "^11.3.6",
|
"lru-cache": "^11.3.6",
|
||||||
|
"lucide-react": "^1.14.0",
|
||||||
"mysql2": "^3.22.3",
|
"mysql2": "^3.22.3",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
@@ -619,6 +620,8 @@
|
|||||||
|
|
||||||
"lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="],
|
"lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="],
|
||||||
|
|
||||||
|
"lucide-react": ["lucide-react@1.14.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA=="],
|
||||||
|
|
||||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
"citty": "^0.2.2",
|
"citty": "^0.2.2",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"lru-cache": "^11.3.6",
|
"lru-cache": "^11.3.6",
|
||||||
|
"lucide-react": "^1.14.0",
|
||||||
"mysql2": "^3.22.3",
|
"mysql2": "^3.22.3",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
|
||||||
|
|
||||||
|
type Variant = 'default' | 'muted' | 'success' | 'warning' | 'danger' | 'info'
|
||||||
|
|
||||||
|
function cn(...classes: Array<string | false | null | undefined>) {
|
||||||
|
return classes.filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantClass: Record<Variant, string> = {
|
||||||
|
default: 'border-white/10 bg-white/[0.04] text-zinc-100',
|
||||||
|
muted: 'border-white/10 bg-zinc-900/70 text-zinc-400',
|
||||||
|
success: 'border-emerald-400/20 bg-emerald-400/10 text-emerald-300',
|
||||||
|
warning: 'border-amber-400/20 bg-amber-400/10 text-amber-300',
|
||||||
|
danger: 'border-red-400/20 bg-red-400/10 text-red-300',
|
||||||
|
info: 'border-teal-400/20 bg-teal-400/10 text-teal-300',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Badge({
|
||||||
|
className,
|
||||||
|
variant = 'default',
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ComponentPropsWithoutRef<'span'> & { variant?: Variant }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium leading-none',
|
||||||
|
variantClass[variant],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({ className, children, ...props }: ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('rounded-2xl border border-white/[0.08] bg-zinc-950/60 shadow-2xl shadow-black/20', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({ className, children, ...props }: ComponentPropsWithoutRef<'button'>) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center gap-2 rounded-lg border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-medium text-zinc-100 transition-colors hover:border-white/20 hover:bg-white/[0.09] disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:bg-white/[0.05]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Input({ className, ...props }: ComponentPropsWithoutRef<'input'>) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
className={cn(
|
||||||
|
'h-10 w-full rounded-lg border border-white/10 bg-zinc-950/80 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 outline-none transition-colors focus:border-teal-400/60 focus:ring-2 focus:ring-teal-400/10',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Select({ className, children, ...props }: ComponentPropsWithoutRef<'select'>) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
className={cn(
|
||||||
|
'h-10 rounded-lg border border-white/10 bg-zinc-950/95 px-3 py-2 text-sm text-zinc-100 outline-none transition-colors [color-scheme:dark] focus:border-teal-400/60 focus:ring-2 focus:ring-teal-400/10',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionTitle({ icon, title, description }: { icon?: ReactNode; title: string; description?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{icon && <div className="mt-0.5 rounded-lg border border-white/10 bg-white/[0.04] p-2 text-teal-300">{icon}</div>}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium text-white">{title}</h3>
|
||||||
|
{description && <p className="mt-1 text-sm text-zinc-400">{description}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+129
-122
@@ -1,5 +1,6 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
|
import { AlertTriangle, ArrowRight, Database, ShieldCheck, Tags, TrendingDown } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
Area,
|
Area,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
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 { Badge, Card, SectionTitle } from '@/components/ui'
|
||||||
import type { DashboardSnapshot, DeviceStatus } from '@/domain/battery'
|
import type { DashboardSnapshot, DeviceStatus } from '@/domain/battery'
|
||||||
import { BATTERY_LIST_SORT, DEVICE_STATUS } 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]
|
const f = soh.forecast[i]
|
||||||
if (f) {
|
if (f) {
|
||||||
chartData.push({
|
chartData.push({
|
||||||
@@ -57,16 +61,16 @@ function buildChartData(soh: DashboardSnapshot['soh']) {
|
|||||||
return chartData
|
return chartData
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusColorMap: Record<DeviceStatus, string> = {
|
const statusVariantMap: Record<DeviceStatus, 'success' | 'warning' | 'danger'> = {
|
||||||
[DEVICE_STATUS.HEALTHY]: 'text-emerald-400',
|
[DEVICE_STATUS.HEALTHY]: 'success',
|
||||||
[DEVICE_STATUS.WATCH]: 'text-amber-400',
|
[DEVICE_STATUS.WATCH]: 'warning',
|
||||||
[DEVICE_STATUS.WARNING]: 'text-red-400',
|
[DEVICE_STATUS.WARNING]: 'danger',
|
||||||
}
|
}
|
||||||
|
|
||||||
const severityColorMap: Record<DashboardSnapshot['events'][number]['severity'], string> = {
|
const severityVariantMap: Record<DashboardSnapshot['events'][number]['severity'], 'danger' | 'warning' | 'muted'> = {
|
||||||
高: 'text-red-400',
|
高: 'danger',
|
||||||
中: 'text-amber-400',
|
中: 'warning',
|
||||||
低: 'text-zinc-400',
|
低: 'muted',
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatChartTooltip(value: ValueType | undefined, name: NameType | undefined) {
|
function formatChartTooltip(value: ValueType | undefined, name: NameType | undefined) {
|
||||||
@@ -145,30 +149,20 @@ function Dashboard() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="animate-fade-up mb-12 flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
|
<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="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">
|
<Badge variant="info" className="mb-4">
|
||||||
智能戒指电池健康预测模型 (v2.4)
|
<Database className="size-3.5" /> MySQL 实时记录 + AI 预测 API
|
||||||
</div>
|
</Badge>
|
||||||
<h1 className="text-4xl font-light tracking-tight text-white sm:text-5xl">SoH 预测与风险洞察</h1>
|
<h1 className="text-4xl font-light tracking-tight text-white sm:text-5xl">SoH 预测与风险洞察</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-3 text-right">
|
<div className="flex flex-col items-end gap-3 text-right">
|
||||||
<div className="flex items-center gap-6 text-sm">
|
<Badge variant="muted">仅展示真实记录与预测接口返回值</Badge>
|
||||||
<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>
|
<p className="text-xs tabular-nums text-[#71717A]">数据更新时间: {updatedAt}</p>
|
||||||
<Link
|
<Link
|
||||||
to="/batteries"
|
to="/batteries"
|
||||||
search={{ pageSize: 50, sort: BATTERY_LIST_SORT.CREATED_AT_DESC, cursors: [] }}
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -192,7 +186,7 @@ function Dashboard() {
|
|||||||
<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预测不可用' : '基线健康'}
|
{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>
|
||||||
@@ -244,20 +238,23 @@ function Dashboard() {
|
|||||||
|
|
||||||
{/* SoH Trend Chart */}
|
{/* SoH Trend Chart */}
|
||||||
<section className="animate-fade-up delay-300 mb-12">
|
<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">
|
<header className="mb-8 flex flex-wrap items-end justify-between gap-4">
|
||||||
<div>
|
<SectionTitle
|
||||||
<h3 className="text-xl font-medium text-white">SoH 衰减趋势与 90 天预测</h3>
|
icon={<TrendingDown className="size-4" />}
|
||||||
<p className="mt-1 text-sm text-[#A1A1AA]">基于历史 12 个月真实数据与未来 3 个月模型预测区间</p>
|
title="SoH 预测点位"
|
||||||
</div>
|
description="图表只展示 AI 预测 API 返回的当前、30 天、90 天聚合点;没有真实 SoH 历史时不补假趋势。"
|
||||||
|
/>
|
||||||
<div className="flex items-center gap-6 text-sm text-[#A1A1AA]">
|
<div className="flex items-center gap-6 text-sm text-[#A1A1AA]">
|
||||||
<span className="inline-flex items-center gap-2">
|
{soh.history.length > 0 && (
|
||||||
<span className="h-2 w-4 rounded-full bg-teal-400" />
|
<span className="inline-flex items-center gap-2">
|
||||||
历史观测值
|
<span className="h-2 w-4 rounded-full bg-teal-400" />
|
||||||
</span>
|
历史观测值
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
<span className="h-2 w-4 rounded-full bg-indigo-400" />
|
<span className="h-2 w-4 rounded-full bg-indigo-400" />
|
||||||
模型预测值
|
API 预测值
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -361,7 +358,7 @@ function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Two-column grid */}
|
{/* Two-column grid */}
|
||||||
@@ -370,7 +367,9 @@ function Dashboard() {
|
|||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Risk Distribution */}
|
{/* Risk Distribution */}
|
||||||
<div>
|
<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 className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 flex justify-between text-sm">
|
<div className="mb-2 flex justify-between text-sm">
|
||||||
@@ -428,7 +427,7 @@ function Dashboard() {
|
|||||||
|
|
||||||
{/* Regional Performance */}
|
{/* Regional Performance */}
|
||||||
<div>
|
<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">
|
<div className="space-y-4">
|
||||||
{batchPerformance.length > 0 ? (
|
{batchPerformance.length > 0 ? (
|
||||||
batchPerformance.map((item) => (
|
batchPerformance.map((item) => (
|
||||||
@@ -458,7 +457,9 @@ function Dashboard() {
|
|||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Event Timeline */}
|
{/* Event Timeline */}
|
||||||
<div>
|
<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">
|
<div className="relative border-l border-white/10 pl-5">
|
||||||
{events.length > 0 ? (
|
{events.length > 0 ? (
|
||||||
events.map((event) => (
|
events.map((event) => (
|
||||||
@@ -470,7 +471,7 @@ function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
<div className="mb-1 flex items-center gap-3">
|
<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 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>
|
</div>
|
||||||
<h4 className="text-sm font-medium text-[#F4F4F5]">{event.title}</h4>
|
<h4 className="text-sm font-medium text-[#F4F4F5]">{event.title}</h4>
|
||||||
<p className="mt-1 text-sm leading-relaxed text-[#A1A1AA]">{event.detail}</p>
|
<p className="mt-1 text-sm leading-relaxed text-[#A1A1AA]">{event.detail}</p>
|
||||||
@@ -484,19 +485,18 @@ function Dashboard() {
|
|||||||
|
|
||||||
{/* Risk Factor Frequency */}
|
{/* Risk Factor Frequency */}
|
||||||
<div>
|
<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">
|
<div className="flex flex-wrap gap-2">
|
||||||
{riskFactorCounts.length > 0 ? (
|
{riskFactorCounts.length > 0 ? (
|
||||||
riskFactorCounts.map((item) => (
|
riskFactorCounts.map((item) => (
|
||||||
<div
|
<Badge key={item.factor} variant={item.factor.includes('不可用') ? 'warning' : 'default'}>
|
||||||
key={item.factor}
|
{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="rounded-full bg-white/10 px-1.5 py-0.5 tabular-nums text-white">
|
||||||
>
|
|
||||||
<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}
|
{item.count}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</Badge>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-[#71717A]">暂无数据</div>
|
<div className="text-sm text-[#71717A]">暂无数据</div>
|
||||||
@@ -514,81 +514,88 @@ function Dashboard() {
|
|||||||
<div className="mb-6 flex items-end justify-between">
|
<div className="mb-6 flex items-end justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-medium text-white">高风险设备清单与预测明细</h3>
|
<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>
|
</div>
|
||||||
<div className="overflow-x-auto rounded-xl border border-white/[0.06] bg-white/[0.02]">
|
<Card className="overflow-hidden">
|
||||||
<table className="w-full min-w-[1000px] border-collapse text-left text-sm">
|
<div className="overflow-x-auto">
|
||||||
<thead>
|
<table className="w-full min-w-[1000px] border-collapse text-left text-sm">
|
||||||
<tr className="border-b border-white/10 bg-white/[0.02] text-[#A1A1AA]">
|
<thead>
|
||||||
<th className="px-6 py-4 font-medium">设备标识</th>
|
<tr className="border-b border-white/10 bg-white/[0.02] text-zinc-400">
|
||||||
<th className="px-6 py-4 font-medium">生产批次</th>
|
<th className="px-6 py-4 font-medium whitespace-nowrap">设备标识</th>
|
||||||
<th className="px-6 py-4 font-medium">当前 SoH</th>
|
<th className="px-6 py-4 font-medium whitespace-nowrap">设备型号</th>
|
||||||
<th className="px-6 py-4 font-medium">30天预测</th>
|
<th className="px-6 py-4 font-medium whitespace-nowrap">当前 SoH</th>
|
||||||
<th className="px-6 py-4 font-medium">90天预测</th>
|
<th className="px-6 py-4 font-medium whitespace-nowrap">30天预测</th>
|
||||||
<th className="px-6 py-4 font-medium">风险评分</th>
|
<th className="px-6 py-4 font-medium whitespace-nowrap">90天预测</th>
|
||||||
<th className="px-6 py-4 font-medium">状态</th>
|
<th className="px-6 py-4 font-medium whitespace-nowrap">风险评分</th>
|
||||||
<th className="px-6 py-4 font-medium">主要风险因子</th>
|
<th className="px-6 py-4 font-medium whitespace-nowrap">状态</th>
|
||||||
</tr>
|
<th className="px-6 py-4 font-medium whitespace-nowrap">主要风险因子</th>
|
||||||
</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>
|
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
</thead>
|
||||||
</tbody>
|
<tbody className="divide-y divide-white/5">
|
||||||
</table>
|
{devices.length > 0 ? (
|
||||||
</div>
|
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>
|
</section>
|
||||||
|
|
||||||
{/* Bottom Row */}
|
{/* Bottom Row */}
|
||||||
@@ -622,7 +629,7 @@ function Dashboard() {
|
|||||||
|
|
||||||
{/* Firmware Comparison */}
|
{/* Firmware Comparison */}
|
||||||
<div>
|
<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="rounded-xl border border-white/[0.06] bg-white/[0.02] p-5">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{firmwareHealth.length > 0 ? (
|
{firmwareHealth.length > 0 ? (
|
||||||
|
|||||||
@@ -1 +1,24 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
background: #09090b;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
background: #09090b;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-width: 320px;
|
||||||
|
margin: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top, rgba(20, 184, 166, 0.08), transparent 34rem),
|
||||||
|
linear-gradient(180deg, #09090b 0%, #0b0f12 100%);
|
||||||
|
color: #f4f4f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
select option {
|
||||||
|
background: #09090b;
|
||||||
|
color: #f4f4f5;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user