Compare commits
3 Commits
e9568bca8c
...
4571cee2a1
| Author | SHA1 | Date | |
|---|---|---|---|
| 4571cee2a1 | |||
| 38943f239f | |||
| 5d9aa660d8 |
@@ -23,6 +23,7 @@
|
||||
"citty": "^0.2.2",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"lru-cache": "^11.3.6",
|
||||
"lucide-react": "^1.14.0",
|
||||
"mysql2": "^3.22.3",
|
||||
"react": "^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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"citty": "^0.2.2",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"lru-cache": "^11.3.6",
|
||||
"lucide-react": "^1.14.0",
|
||||
"mysql2": "^3.22.3",
|
||||
"react": "^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>
|
||||
)
|
||||
}
|
||||
@@ -65,6 +65,12 @@ describe('battery domain', () => {
|
||||
expect(snapshot.devices.every((device) => device.soh === null)).toBe(true)
|
||||
expect(snapshot.devices.every((device) => device.soh30d === null)).toBe(true)
|
||||
expect(snapshot.devices.every((device) => device.soh90d === null)).toBe(true)
|
||||
expect(snapshot.devices.every((device) => device.soh60d === null)).toBe(true)
|
||||
expect(snapshot.devices.every((device) => device.cycles === 0)).toBe(true)
|
||||
expect(snapshot.devices.every((device) => device.temperature === 0)).toBe(true)
|
||||
expect(snapshot.devices.every((device) => device.chargeEfficiency === 0)).toBe(true)
|
||||
expect(snapshot.devices[0]?.firmware).toBe('v3.8.2')
|
||||
expect(snapshot.devices[1]?.firmware).toBe('未提供')
|
||||
expect(snapshot.soh.history).toHaveLength(0)
|
||||
expect(snapshot.soh.forecast).toHaveLength(0)
|
||||
expect(snapshot.summary.totalDevices).toBe(snapshot.devices.length)
|
||||
@@ -106,7 +112,16 @@ describe('battery domain', () => {
|
||||
expect(predicted?.sohSource).toBe('prediction')
|
||||
expect(predicted?.soh30d).toBe(58)
|
||||
expect(predicted?.soh90d).toBe(52)
|
||||
expect(predicted?.soh60d).toBeNull()
|
||||
expect(predicted?.cycles).toBe(6)
|
||||
expect(predicted?.firmware).toBe('v3.8.2')
|
||||
expect(predicted?.status).toBe(DEVICE_STATUS.WARNING)
|
||||
expect(predicted?.firmware).toBe('XGBoost')
|
||||
expect(predicted?.temperature).toBe(0)
|
||||
expect(predicted?.chargeEfficiency).toBe(0)
|
||||
expect(snapshot.soh.history).toHaveLength(0)
|
||||
expect(snapshot.soh.forecast).toHaveLength(3)
|
||||
expect(snapshot.soh.forecast[0]).toEqual({ month: '当前', value: 60 })
|
||||
expect(snapshot.soh.forecast[1]).toEqual({ month: '30天', value: 58 })
|
||||
expect(snapshot.soh.forecast[2]).toEqual({ month: '90天', value: 52 })
|
||||
})
|
||||
})
|
||||
|
||||
+64
-74
@@ -149,19 +149,9 @@ const clamp = (value: number, min: number, max: number) => Math.min(max, Math.ma
|
||||
|
||||
const pad2 = (value: number) => value.toString().padStart(2, '0')
|
||||
|
||||
const addMonths = (date: Date, months: number) =>
|
||||
new Date(date.getFullYear(), date.getMonth() + months, date.getDate(), date.getHours(), 0, 0, 0)
|
||||
|
||||
const addHours = (date: Date, hours: number) => new Date(date.getTime() + hours * 60 * 60 * 1000)
|
||||
|
||||
const formatMonthLabel = (date: Date) => `${date.getFullYear().toString().slice(-2)}.${pad2(date.getMonth() + 1)}`
|
||||
|
||||
const formatDateTime = (date: Date) =>
|
||||
`${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`
|
||||
|
||||
const formatEventTime = (date: Date) =>
|
||||
`${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`
|
||||
|
||||
export function getDeviceStatus(soh: number): DeviceStatus {
|
||||
if (soh <= SOH_THRESHOLDS.WARNING) return DEVICE_STATUS.WARNING
|
||||
if (soh <= SOH_THRESHOLDS.WATCH) return DEVICE_STATUS.WATCH
|
||||
@@ -249,7 +239,7 @@ export function createBatteriesResponse(
|
||||
}
|
||||
}
|
||||
|
||||
function toFleetUnit(item: BatteryInfo, index: number, prediction?: BatteryPrediction): FleetUnit {
|
||||
function toFleetUnit(item: BatteryInfo, prediction?: BatteryPrediction): FleetUnit {
|
||||
const hasPrediction = Boolean(prediction)
|
||||
const soh = prediction ? round1(clamp(prediction.nowSoh, 0, 100)) : null
|
||||
const status = prediction
|
||||
@@ -266,25 +256,21 @@ function toFleetUnit(item: BatteryInfo, index: number, prediction?: BatteryPredi
|
||||
if (item.remark?.includes('v3.7')) riskFactors.push('旧固件')
|
||||
if (prediction?.riskLevel) riskFactors.push(`AI风险:${prediction.riskLevel}`)
|
||||
|
||||
const thermalPressure = index % 3
|
||||
const soh30d = prediction ? round1(clamp(prediction.monthSoh, 0, 100)) : null
|
||||
const soh90d = prediction ? round1(clamp(prediction.trmonthSoh, 0, 100)) : null
|
||||
const soh60d = soh30d !== null && soh90d !== null ? round1((soh30d + soh90d) / 2) : null
|
||||
const temperature = round1(29.5 + thermalPressure * 2.1 + (item.isLowPower ? 1.4 : 0))
|
||||
const chargeEfficiency = round1(clamp(91 + item.power / 12 - riskFactors.length * 1.8, 80, 98))
|
||||
const riskScore = Math.round(
|
||||
clamp(
|
||||
prediction?.riskScore ?? 18 + riskFactors.length * 10 + thermalPressure * 4 + (item.isLowPower ? 18 : 0),
|
||||
8,
|
||||
96,
|
||||
),
|
||||
)
|
||||
const soh60d = null
|
||||
const temperature = 0
|
||||
const chargeEfficiency = 0
|
||||
const fallbackRiskScore =
|
||||
(item.isLowPower || item.power <= SOH_THRESHOLDS.LOW_POWER ? 60 : 0) +
|
||||
(item.powerStatus === POWER_STATUS.CHARGING ? 20 : 0)
|
||||
const riskScore = Math.round(clamp(prediction?.riskScore ?? fallbackRiskScore, 0, 100))
|
||||
|
||||
return {
|
||||
id: item.devName || item.mac,
|
||||
batch: item.devModel,
|
||||
firmware: prediction?.modelName ?? item.remark ?? 'unknown',
|
||||
cycles: 120 + index * 17 + Math.round((100 - (soh ?? item.power)) * 2.2),
|
||||
firmware: item.remark ?? '未提供',
|
||||
cycles: prediction?.cyclesUsed ?? 0,
|
||||
soh,
|
||||
sohSource: prediction ? 'prediction' : 'unavailable',
|
||||
soh30d,
|
||||
@@ -298,28 +284,24 @@ function toFleetUnit(item: BatteryInfo, index: number, prediction?: BatteryPredi
|
||||
}
|
||||
}
|
||||
|
||||
function createSohResponse(devices: FleetUnit[], now: Date) {
|
||||
function createSohResponse(devices: FleetUnit[]) {
|
||||
const predictedDevices = devices.filter((unit) => unit.soh !== null)
|
||||
if (predictedDevices.length === 0) return { history: [], forecast: [] }
|
||||
|
||||
const avgSoh = averageNullable(predictedDevices.map((unit) => unit.soh)) ?? 0
|
||||
const monthlyDrop =
|
||||
0.45 + predictedDevices.reduce((sum, unit) => sum + unit.riskScore, 0) / predictedDevices.length / 160
|
||||
const avgNow = averageNullable(predictedDevices.map((unit) => unit.soh))
|
||||
const avgMonth = averageNullable(predictedDevices.map((unit) => unit.soh30d))
|
||||
const avgTrmonth = averageNullable(predictedDevices.map((unit) => unit.soh90d))
|
||||
|
||||
const history = Array.from({ length: 12 }, (_, index) => {
|
||||
const monthOffset = index - 11
|
||||
return {
|
||||
month: formatMonthLabel(addMonths(now, monthOffset)),
|
||||
value: round1(clamp(avgSoh + Math.abs(monthOffset) * monthlyDrop, avgSoh, 100)),
|
||||
}
|
||||
})
|
||||
const currentValue = history.at(-1)?.value ?? round1(avgSoh)
|
||||
const forecast = Array.from({ length: 4 }, (_, index) => ({
|
||||
month: formatMonthLabel(addMonths(now, index)),
|
||||
value: index === 0 ? currentValue : round1(clamp(avgSoh - monthlyDrop * index, 0, 100)),
|
||||
}))
|
||||
const forecast = [
|
||||
avgNow === null ? null : { month: '当前', value: round1(clamp(avgNow, 0, 100)) },
|
||||
avgMonth === null ? null : { month: '30天', value: round1(clamp(avgMonth, 0, 100)) },
|
||||
avgTrmonth === null ? null : { month: '90天', value: round1(clamp(avgTrmonth, 0, 100)) },
|
||||
].filter((point): point is { month: string; value: number } => point !== null)
|
||||
|
||||
return { history, forecast }
|
||||
return {
|
||||
history: [],
|
||||
forecast,
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeBy<T extends string>(items: FleetUnit[], getKey: (item: FleetUnit) => T) {
|
||||
@@ -391,8 +373,10 @@ function createSummary(devices: FleetUnit[], now: Date) {
|
||||
)
|
||||
.map(([factor, count]) => ({ factor, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
const weakestBatch = batchPerformance.at(-1)?.batch ?? '当前设备'
|
||||
const weakestFirmware = firmwareHealth.at(-1)?.firmware ?? 'unknown'
|
||||
const weakestModel = batchPerformance.at(-1)?.batch ?? '当前设备型号'
|
||||
const weakestRemark = firmwareHealth.at(-1)?.firmware ?? '未提供备注'
|
||||
const predictedDevices = devices.filter((unit) => unit.soh !== null).length
|
||||
const missingPredictionDevices = totalDevices - predictedDevices
|
||||
|
||||
return {
|
||||
totalDevices,
|
||||
@@ -409,36 +393,32 @@ function createSummary(devices: FleetUnit[], now: Date) {
|
||||
executiveSummary:
|
||||
avgSoh === null
|
||||
? '当前 AI SoH 预测不可用,页面仅展示 MySQL 采集电量、充电状态与低电量风险。请检查预测服务配置或历史数据量。'
|
||||
: `当前电池健康度总体可控,重点风险集中在 ${weakestBatch} 批次与 ${weakestFirmware} 固件设备。建议优先跟踪低电量、充电状态与未来 90 天 SoH 变化。`,
|
||||
: `当前共有 ${predictedDevices} 台设备返回 SoH 预测,${missingPredictionDevices} 台设备暂无预测。重点关注 ${weakestModel} 型号与 ${weakestRemark} 备注设备,优先处理低电量和充电中的设备,并在下次同步后复查缺失预测与未来 30/90 天模型预测。`,
|
||||
}
|
||||
}
|
||||
|
||||
function createEvents(devices: FleetUnit[], now: Date) {
|
||||
const sortedDevices = devices.slice().sort((a, b) => b.riskScore - a.riskScore)
|
||||
const first = sortedDevices[0]
|
||||
if (devices.length === 0) return []
|
||||
|
||||
if (!first) return []
|
||||
|
||||
const second = sortedDevices[1] ?? first
|
||||
const predictedDevices = devices.filter((unit) => unit.soh !== null)
|
||||
const warningDevices = devices.filter((unit) => unit.status === DEVICE_STATUS.WARNING)
|
||||
const missingPredictionDevices = devices.length - predictedDevices.length
|
||||
|
||||
return [
|
||||
{
|
||||
time: formatEventTime(addHours(now, -2)),
|
||||
title: `${first.id} 进入重点观察队列`,
|
||||
detail:
|
||||
first.soh === null
|
||||
? `${first.id} 当前 SoH 预测不可用,综合风险评分 ${first.riskScore}。`
|
||||
: `${first.id} 当前 SoH 为 ${first.soh.toFixed(1)}%,综合风险评分 ${first.riskScore}。`,
|
||||
severity: first.status === DEVICE_STATUS.WARNING ? EVENT_SEVERITY.HIGH : EVENT_SEVERITY.MEDIUM,
|
||||
time: formatDateTime(now),
|
||||
title: '风险快照',
|
||||
detail: `本次快照包含 ${devices.length} 台设备,其中 ${predictedDevices.length} 台返回真实 SoH 预测,${warningDevices.length} 台处于预警状态。`,
|
||||
severity: warningDevices.length > 0 ? EVENT_SEVERITY.HIGH : EVENT_SEVERITY.LOW,
|
||||
},
|
||||
{
|
||||
time: formatEventTime(addHours(now, -5)),
|
||||
title: `${second.batch} 批次健康度趋势更新`,
|
||||
time: formatDateTime(now),
|
||||
title: '预测可用性快照',
|
||||
detail:
|
||||
second.soh90d === null
|
||||
? `${second.batch} 批次未来 90 天 SoH 预测不可用。`
|
||||
: `${second.batch} 批次未来 90 天预测 SoH 为 ${second.soh90d.toFixed(1)}%。`,
|
||||
severity: second.status === DEVICE_STATUS.HEALTHY ? EVENT_SEVERITY.LOW : EVENT_SEVERITY.MEDIUM,
|
||||
missingPredictionDevices > 0
|
||||
? `当前有 ${missingPredictionDevices} 台设备暂无 SoH 预测,对应图表与卡片保留为空值。`
|
||||
: '当前所有设备均已返回 SoH 预测,图表仅展示真实预测点。',
|
||||
severity: missingPredictionDevices > 0 ? EVENT_SEVERITY.MEDIUM : EVENT_SEVERITY.LOW,
|
||||
},
|
||||
] satisfies DashboardSnapshot['events']
|
||||
}
|
||||
@@ -447,21 +427,31 @@ function createStrategies(devices: FleetUnit[]) {
|
||||
if (devices.length === 0) return []
|
||||
|
||||
const warningDevices = devices.filter((unit) => unit.status === DEVICE_STATUS.WARNING)
|
||||
const chargingDevices = devices.filter((unit) => unit.riskFactors.includes('充电中'))
|
||||
const first = devices.slice().sort((a, b) => b.riskScore - a.riskScore)[0]
|
||||
const powerAttentionDevices = devices.filter(
|
||||
(unit) => unit.riskFactors.includes('充电中') || unit.riskFactors.includes('低电量'),
|
||||
)
|
||||
const missingPredictionDevices = devices.filter((unit) => unit.soh === null)
|
||||
|
||||
return [
|
||||
{
|
||||
name: '预警设备优先维护',
|
||||
impact: `预计高风险设备减少 ${Math.max(10, warningDevices.length * 12)}%`,
|
||||
scope: first ? `${first.id} 等 ${Math.max(1, warningDevices.length)} 台设备` : '当前设备',
|
||||
eta: '48 小时内完成',
|
||||
name: '优先处理预警设备',
|
||||
impact: `当前有 ${warningDevices.length} 台设备处于预警状态,建议先复核供电、连接与预测结果。`,
|
||||
scope: warningDevices.length > 0 ? `${warningDevices.length} 台预警设备` : '当前设备',
|
||||
eta: '本次巡检周期内',
|
||||
},
|
||||
{
|
||||
name: '充电策略复核',
|
||||
impact: `覆盖 ${Math.max(1, chargingDevices.length)} 台充电中设备`,
|
||||
scope: '充电中与低电量设备',
|
||||
eta: '本周完成首轮验证',
|
||||
name: '补齐预测覆盖',
|
||||
impact:
|
||||
missingPredictionDevices.length > 0
|
||||
? `当前有 ${missingPredictionDevices.length} 台设备暂无 SoH 预测,建议在下次同步后复查。`
|
||||
: `当前已有 ${devices.length} 台设备返回预测结果,可继续观察真实变化。`,
|
||||
scope:
|
||||
powerAttentionDevices.length > 0
|
||||
? `${powerAttentionDevices.length} 台充电中或低电量设备`
|
||||
: missingPredictionDevices.length > 0
|
||||
? `${missingPredictionDevices.length} 台缺失预测设备`
|
||||
: '当前设备',
|
||||
eta: '下次同步后复查',
|
||||
},
|
||||
] satisfies DashboardSnapshot['strategies']
|
||||
}
|
||||
@@ -471,11 +461,11 @@ export function createDashboardSnapshot(
|
||||
now = new Date(),
|
||||
predictions: ReadonlyMap<string, BatteryPrediction> = new Map(),
|
||||
): DashboardSnapshot {
|
||||
const devices = items.map((item, index) => toFleetUnit(item, index, predictions.get(item.mac)))
|
||||
const devices = items.map((item) => toFleetUnit(item, predictions.get(item.mac)))
|
||||
|
||||
return {
|
||||
devices,
|
||||
soh: createSohResponse(devices, now),
|
||||
soh: createSohResponse(devices),
|
||||
events: createEvents(devices, now),
|
||||
strategies: createStrategies(devices),
|
||||
summary: createSummary(devices, now),
|
||||
|
||||
+185
-122
@@ -1,9 +1,11 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'
|
||||
import { ArrowLeft, Battery, BatteryCharging, BatteryLow, Database, FilterX, Search, Zap } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
import { orpc } from '@/client/orpc'
|
||||
import { Badge, Button, Card, Input, SectionTitle, Select } from '@/components/ui'
|
||||
import type { BatteryInfo, BatteryListSort, PowerStatus } from '@/domain/battery'
|
||||
import { BATTERY_LIST_SORT, BATTERY_LIST_SORT_VALUES, POWER_STATUS, POWER_STATUS_VALUES } from '@/domain/battery'
|
||||
|
||||
@@ -56,10 +58,10 @@ const powerStatusLabel: Record<PowerStatus, string> = {
|
||||
[POWER_STATUS.FULL]: '已充满',
|
||||
}
|
||||
|
||||
const powerStatusColor: Record<PowerStatus, string> = {
|
||||
[POWER_STATUS.NOT_CHARGING]: 'text-zinc-400',
|
||||
[POWER_STATUS.CHARGING]: 'text-teal-400',
|
||||
[POWER_STATUS.FULL]: 'text-emerald-400',
|
||||
const powerStatusVariant: Record<PowerStatus, 'muted' | 'info' | 'success'> = {
|
||||
[POWER_STATUS.NOT_CHARGING]: 'muted',
|
||||
[POWER_STATUS.CHARGING]: 'info',
|
||||
[POWER_STATUS.FULL]: 'success',
|
||||
}
|
||||
|
||||
function powerBarColor(power: number, isLowPower: boolean): string {
|
||||
@@ -158,7 +160,7 @@ function BatteriesPage() {
|
||||
header: '状态',
|
||||
cell: (info) => {
|
||||
const status = info.getValue()
|
||||
return <span className={`text-xs ${powerStatusColor[status]}`}>{powerStatusLabel[status]}</span>
|
||||
return <Badge variant={powerStatusVariant[status]}>{powerStatusLabel[status]}</Badge>
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor('createTime', {
|
||||
@@ -177,6 +179,21 @@ function BatteriesPage() {
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
})
|
||||
|
||||
const hasActiveFilters = Boolean(search.search || search.lowPower || search.powerStatus !== undefined)
|
||||
const clearFilters = () => {
|
||||
setLocalSearch('')
|
||||
navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
search: undefined,
|
||||
lowPower: undefined,
|
||||
powerStatus: undefined,
|
||||
cursor: undefined,
|
||||
cursors: [],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (!isPlaceholderData && data?.nextCursor) {
|
||||
const nextCursor = data.nextCursor
|
||||
@@ -222,132 +239,184 @@ function BatteriesPage() {
|
||||
|
||||
<div className="relative z-10 mx-auto max-w-7xl px-6 py-8">
|
||||
<header>
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h1 className="font-light text-3xl tracking-tight">设备电池实时状态</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
<Badge variant="info" className="mb-4">
|
||||
<Database className="size-3.5" /> MySQL 实时记录
|
||||
</Badge>
|
||||
<h1 className="text-3xl font-light tracking-tight text-white">设备电池实时状态</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-zinc-400">
|
||||
只展示 ls_battery_info 最新采集记录,筛选、排序和分页均通过 ORPC 服务端查询完成。
|
||||
</p>
|
||||
<p className="mt-2 text-xs tabular-nums text-zinc-500">
|
||||
{data ? `更新于 ${new Date(data.updatedAt).toLocaleString('zh-CN')}` : '加载中…'}
|
||||
</p>
|
||||
</div>
|
||||
<nav className="text-xs">
|
||||
<Link to="/" className="text-zinc-500 hover:text-teal-400 transition-colors">
|
||||
← 返回 SoH 看板
|
||||
<nav>
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-zinc-300 transition-colors hover:border-teal-400/30 hover:text-teal-300"
|
||||
>
|
||||
<ArrowLeft className="size-4" /> 返回 SoH 看板
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<dl className="mt-8 grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-xl border border-white/5 bg-white/[0.02] p-5">
|
||||
<dt className="text-xs font-medium text-zinc-500">设备总数</dt>
|
||||
<dd className="mt-2 font-light text-3xl tabular-nums">{data?.total ?? '-'}</dd>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/5 bg-white/[0.02] p-5">
|
||||
<dt className="text-xs font-medium text-zinc-500">低电量</dt>
|
||||
<dd className="mt-2 font-light text-3xl tabular-nums text-red-400">{data?.lowPower ?? '-'}</dd>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/5 bg-white/[0.02] p-5">
|
||||
<dt className="text-xs font-medium text-zinc-500">充电中</dt>
|
||||
<dd className="mt-2 font-light text-3xl tabular-nums text-teal-400">{data?.charging ?? '-'}</dd>
|
||||
</div>
|
||||
<Card className="p-5">
|
||||
<dt className="flex items-center gap-2 text-xs font-medium text-zinc-500">
|
||||
<Battery className="size-4 text-zinc-400" /> 设备总数
|
||||
</dt>
|
||||
<dd className="mt-3 text-3xl font-light tabular-nums text-white">{data?.total ?? '-'}</dd>
|
||||
</Card>
|
||||
<Card className="p-5">
|
||||
<dt className="flex items-center gap-2 text-xs font-medium text-zinc-500">
|
||||
<BatteryLow className="size-4 text-red-400" /> 低电量
|
||||
</dt>
|
||||
<dd className="mt-3 text-3xl font-light tabular-nums text-red-300">{data?.lowPower ?? '-'}</dd>
|
||||
</Card>
|
||||
<Card className="p-5">
|
||||
<dt className="flex items-center gap-2 text-xs font-medium text-zinc-500">
|
||||
<BatteryCharging className="size-4 text-teal-300" /> 充电中
|
||||
</dt>
|
||||
<dd className="mt-3 text-3xl font-light tabular-nums text-teal-300">{data?.charging ?? '-'}</dd>
|
||||
</Card>
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
<section className="mt-10">
|
||||
{/* Controls */}
|
||||
<div className="mb-6 flex flex-wrap items-center gap-4">
|
||||
<div className="relative flex-1 min-w-[240px]">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索设备名称或 MAC..."
|
||||
maxLength={100}
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2 text-sm focus:border-teal-500/50 focus:outline-none focus:ring-1 focus:ring-teal-500/50"
|
||||
value={localSearch}
|
||||
onChange={(e) => setLocalSearch(e.target.value)}
|
||||
<Card className="mb-6 p-5">
|
||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<SectionTitle
|
||||
icon={<Search className="size-4" />}
|
||||
title="筛选设备"
|
||||
description="按真实采集字段筛选,不在前端伪造或补齐记录。"
|
||||
/>
|
||||
{hasActiveFilters && (
|
||||
<Button type="button" className="h-9 px-3 text-xs" onClick={clearFilters}>
|
||||
<FilterX className="size-3.5" /> 清除筛选
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="flex flex-col gap-2 min-w-[260px] flex-1">
|
||||
<label htmlFor="search-input" className="text-xs font-medium text-zinc-500">
|
||||
设备名称 / MAC
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-zinc-600" />
|
||||
<Input
|
||||
id="search-input"
|
||||
type="text"
|
||||
placeholder="搜索设备名称或 MAC..."
|
||||
maxLength={100}
|
||||
className="pl-9"
|
||||
value={localSearch}
|
||||
onChange={(e) => setLocalSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<select
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm focus:outline-none"
|
||||
value={search.powerStatus ?? ''}
|
||||
onChange={(e) => {
|
||||
navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
powerStatus: parsePowerStatus(e.target.value),
|
||||
cursor: undefined,
|
||||
cursors: [],
|
||||
}),
|
||||
})
|
||||
}}
|
||||
>
|
||||
<option value="">所有充电状态</option>
|
||||
<option value={POWER_STATUS.NOT_CHARGING}>未充电</option>
|
||||
<option value={POWER_STATUS.CHARGING}>充电中</option>
|
||||
<option value={POWER_STATUS.FULL}>已充满</option>
|
||||
</select>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="power-status-select" className="text-xs font-medium text-zinc-500">
|
||||
充电状态
|
||||
</label>
|
||||
<Select
|
||||
id="power-status-select"
|
||||
value={search.powerStatus ?? ''}
|
||||
onChange={(e) => {
|
||||
navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
powerStatus: parsePowerStatus(e.target.value),
|
||||
cursor: undefined,
|
||||
cursors: [],
|
||||
}),
|
||||
})
|
||||
}}
|
||||
>
|
||||
<option value="">所有充电状态</option>
|
||||
<option value={POWER_STATUS.NOT_CHARGING}>未充电</option>
|
||||
<option value={POWER_STATUS.CHARGING}>充电中</option>
|
||||
<option value={POWER_STATUS.FULL}>已充满</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-white/10 bg-white/5 text-teal-500 focus:ring-teal-500/50"
|
||||
checked={search.lowPower ?? false}
|
||||
onChange={(e) =>
|
||||
navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
lowPower: e.target.checked || undefined,
|
||||
cursor: undefined,
|
||||
cursors: [],
|
||||
}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
仅看低电量
|
||||
</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="sort-select" className="text-xs font-medium text-zinc-500">
|
||||
排序
|
||||
</label>
|
||||
<Select
|
||||
id="sort-select"
|
||||
value={search.sort}
|
||||
onChange={(e) => {
|
||||
navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
sort: parseSort(e.target.value),
|
||||
cursor: undefined,
|
||||
cursors: [],
|
||||
}),
|
||||
})
|
||||
}}
|
||||
>
|
||||
<option value={BATTERY_LIST_SORT.CREATED_AT_DESC}>最新更新</option>
|
||||
<option value={BATTERY_LIST_SORT.CREATED_AT_ASC}>最早更新</option>
|
||||
<option value={BATTERY_LIST_SORT.POWER_DESC}>电量从高到低</option>
|
||||
<option value={BATTERY_LIST_SORT.POWER_ASC}>电量从低到高</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="page-size-select" className="text-xs font-medium text-zinc-500">
|
||||
页大小
|
||||
</label>
|
||||
<Select
|
||||
id="page-size-select"
|
||||
value={search.pageSize}
|
||||
onChange={(e) => {
|
||||
navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
pageSize: parsePageSize(e.target.value),
|
||||
cursor: undefined,
|
||||
cursors: [],
|
||||
}),
|
||||
})
|
||||
}}
|
||||
>
|
||||
<option value="20">20 条/页</option>
|
||||
<option value="50">50 条/页</option>
|
||||
<option value="100">100 条/页</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<select
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm focus:outline-none"
|
||||
value={search.sort}
|
||||
onChange={(e) => {
|
||||
navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
sort: parseSort(e.target.value),
|
||||
cursor: undefined,
|
||||
cursors: [],
|
||||
}),
|
||||
})
|
||||
}}
|
||||
>
|
||||
<option value={BATTERY_LIST_SORT.CREATED_AT_DESC}>最新更新</option>
|
||||
<option value={BATTERY_LIST_SORT.CREATED_AT_ASC}>最早更新</option>
|
||||
<option value={BATTERY_LIST_SORT.POWER_DESC}>电量从高到低</option>
|
||||
<option value={BATTERY_LIST_SORT.POWER_ASC}>电量从低到高</option>
|
||||
</select>
|
||||
<label
|
||||
htmlFor="low-power-checkbox"
|
||||
className="inline-flex h-10 cursor-pointer items-center gap-2 rounded-lg border border-white/10 bg-white/[0.04] px-3 text-sm text-zinc-300 transition-colors hover:bg-white/[0.07]"
|
||||
>
|
||||
<input
|
||||
id="low-power-checkbox"
|
||||
type="checkbox"
|
||||
className="rounded border-white/10 bg-zinc-950 text-teal-500 focus:ring-teal-500/50"
|
||||
checked={search.lowPower ?? false}
|
||||
onChange={(e) =>
|
||||
navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
lowPower: e.target.checked || undefined,
|
||||
cursor: undefined,
|
||||
cursors: [],
|
||||
}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Zap className="size-4 text-amber-300" /> 仅看低电量
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<select
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm focus:outline-none"
|
||||
value={search.pageSize}
|
||||
onChange={(e) => {
|
||||
navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
pageSize: parsePageSize(e.target.value),
|
||||
cursor: undefined,
|
||||
cursors: [],
|
||||
}),
|
||||
})
|
||||
}}
|
||||
>
|
||||
<option value="20">20 条/页</option>
|
||||
<option value="50">50 条/页</option>
|
||||
<option value="100">100 条/页</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/5 bg-white/[0.01] overflow-hidden">
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className={`w-full border-collapse text-left text-sm ${isPlaceholderData ? 'opacity-60' : ''}`}>
|
||||
<thead>
|
||||
@@ -388,7 +457,7 @@ function BatteriesPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="mt-6 flex items-center justify-between text-sm text-zinc-500">
|
||||
<div>
|
||||
@@ -396,22 +465,16 @@ function BatteriesPage() {
|
||||
{data?.total ? ` (共 ${data.total} 台)` : ''}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-4 py-2 hover:bg-white/10 disabled:opacity-30 disabled:hover:bg-white/5 transition-colors"
|
||||
onClick={handlePrevPage}
|
||||
disabled={isPlaceholderData || (!search.cursor && (!search.cursors || search.cursors.length === 0))}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-4 py-2 hover:bg-white/10 disabled:opacity-30 disabled:hover:bg-white/5 transition-colors"
|
||||
onClick={handleNextPage}
|
||||
disabled={isPlaceholderData || !data?.nextCursor}
|
||||
>
|
||||
</Button>
|
||||
<Button type="button" onClick={handleNextPage} disabled={isPlaceholderData || !data?.nextCursor}>
|
||||
下一页
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+129
-122
@@ -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 ? (
|
||||
|
||||
@@ -1 +1,24 @@
|
||||
@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