Compare commits

...

3 Commits

8 changed files with 520 additions and 319 deletions
+3
View File
@@ -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=="],
+1
View File
@@ -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",
+99
View File
@@ -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>
)
}
+16 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { createFileRoute, Link } from '@tanstack/react-router'
import { AlertTriangle, ArrowRight, Database, ShieldCheck, Tags, TrendingDown } from 'lucide-react'
import {
Area,
CartesianGrid,
@@ -13,6 +14,7 @@ import {
} from 'recharts'
import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent'
import { orpc } from '@/client/orpc'
import { Badge, Card, SectionTitle } from '@/components/ui'
import type { DashboardSnapshot, DeviceStatus } from '@/domain/battery'
import { BATTERY_LIST_SORT, DEVICE_STATUS } from '@/domain/battery'
@@ -43,7 +45,9 @@ function buildChartData(soh: DashboardSnapshot['soh']) {
}
}
for (let i = 1; i < soh.forecast.length; i++) {
const forecastStart = chartData.length > 0 ? 1 : 0
for (let i = forecastStart; i < soh.forecast.length; i++) {
const f = soh.forecast[i]
if (f) {
chartData.push({
@@ -57,16 +61,16 @@ function buildChartData(soh: DashboardSnapshot['soh']) {
return chartData
}
const statusColorMap: Record<DeviceStatus, string> = {
[DEVICE_STATUS.HEALTHY]: 'text-emerald-400',
[DEVICE_STATUS.WATCH]: 'text-amber-400',
[DEVICE_STATUS.WARNING]: 'text-red-400',
const statusVariantMap: Record<DeviceStatus, 'success' | 'warning' | 'danger'> = {
[DEVICE_STATUS.HEALTHY]: 'success',
[DEVICE_STATUS.WATCH]: 'warning',
[DEVICE_STATUS.WARNING]: 'danger',
}
const severityColorMap: Record<DashboardSnapshot['events'][number]['severity'], string> = {
: 'text-red-400',
: 'text-amber-400',
: 'text-zinc-400',
const severityVariantMap: Record<DashboardSnapshot['events'][number]['severity'], 'danger' | 'warning' | 'muted'> = {
: 'danger',
: 'warning',
: 'muted',
}
function formatChartTooltip(value: ValueType | undefined, name: NameType | undefined) {
@@ -145,30 +149,20 @@ function Dashboard() {
{/* Header */}
<header className="animate-fade-up mb-12 flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl">
<div className="mb-4 inline-flex items-center gap-2 rounded-md border border-white/10 bg-white/5 px-2.5 py-1 text-xs font-medium text-teal-400">
(v2.4)
</div>
<Badge variant="info" className="mb-4">
<Database className="size-3.5" /> MySQL + AI API
</Badge>
<h1 className="text-4xl font-light tracking-tight text-white sm:text-5xl">SoH </h1>
</div>
<div className="flex flex-col items-end gap-3 text-right">
<div className="flex items-center gap-6 text-sm">
<div>
<span className="text-[#71717A]"> (MAE)</span>
<span className="ml-2 font-medium tabular-nums text-teal-400">1.2%</span>
</div>
<div className="h-4 w-px bg-white/10" />
<div>
<span className="text-[#71717A]"></span>
<span className="ml-2 font-medium tabular-nums text-teal-400">94.5%</span>
</div>
</div>
<Badge variant="muted"></Badge>
<p className="text-xs tabular-nums text-[#71717A]">: {updatedAt}</p>
<Link
to="/batteries"
search={{ pageSize: 50, sort: BATTERY_LIST_SORT.CREATED_AT_DESC, cursors: [] }}
className="text-xs text-teal-400 hover:text-teal-300"
className="inline-flex items-center gap-1 text-xs text-teal-400 hover:text-teal-300"
>
<ArrowRight className="size-3" />
</Link>
</div>
</header>
@@ -192,7 +186,7 @@ function Dashboard() {
<div className="mt-6 flex items-center gap-3 text-sm">
<span className="inline-flex items-center gap-1.5 text-emerald-400">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
{avgSoh === null ? 'AI预测不可用' : '基线健康'}
{avgSoh === null ? 'AI预测不可用' : '预测已返回'}
</span>
<span className="text-[#71717A]">|</span>
<span className="text-[#A1A1AA]"> {totalDevices} </span>
@@ -244,20 +238,23 @@ function Dashboard() {
{/* SoH Trend Chart */}
<section className="animate-fade-up delay-300 mb-12">
<article className="rounded-2xl border border-white/[0.06] bg-white/[0.03] p-8">
<Card className="p-8">
<header className="mb-8 flex flex-wrap items-end justify-between gap-4">
<div>
<h3 className="text-xl font-medium text-white">SoH 90 </h3>
<p className="mt-1 text-sm text-[#A1A1AA]"> 12 3 </p>
</div>
<SectionTitle
icon={<TrendingDown className="size-4" />}
title="SoH 预测点位"
description="图表只展示 AI 预测 API 返回的当前、30 天、90 天聚合点;没有真实 SoH 历史时不补假趋势。"
/>
<div className="flex items-center gap-6 text-sm text-[#A1A1AA]">
<span className="inline-flex items-center gap-2">
<span className="h-2 w-4 rounded-full bg-teal-400" />
</span>
{soh.history.length > 0 && (
<span className="inline-flex items-center gap-2">
<span className="h-2 w-4 rounded-full bg-teal-400" />
</span>
)}
<span className="inline-flex items-center gap-2">
<span className="h-2 w-4 rounded-full bg-indigo-400" />
API
</span>
</div>
</header>
@@ -361,7 +358,7 @@ function Dashboard() {
</div>
)}
</div>
</article>
</Card>
</section>
{/* Two-column grid */}
@@ -370,7 +367,9 @@ function Dashboard() {
<div className="space-y-8">
{/* Risk Distribution */}
<div>
<h3 className="mb-6 text-lg font-medium text-white"></h3>
<div className="mb-6">
<SectionTitle icon={<ShieldCheck className="size-4" />} title="风险分层与结构" />
</div>
<div className="space-y-5">
<div>
<div className="mb-2 flex justify-between text-sm">
@@ -428,7 +427,7 @@ function Dashboard() {
{/* Regional Performance */}
<div>
<h3 className="mb-6 text-lg font-medium text-white"></h3>
<h3 className="mb-6 text-lg font-medium text-white"></h3>
<div className="space-y-4">
{batchPerformance.length > 0 ? (
batchPerformance.map((item) => (
@@ -458,7 +457,9 @@ function Dashboard() {
<div className="space-y-8">
{/* Event Timeline */}
<div>
<h3 className="mb-6 text-lg font-medium text-white"></h3>
<div className="mb-6">
<SectionTitle icon={<AlertTriangle className="size-4" />} title="风险与预测快照" />
</div>
<div className="relative border-l border-white/10 pl-5">
{events.length > 0 ? (
events.map((event) => (
@@ -470,7 +471,7 @@ function Dashboard() {
/>
<div className="mb-1 flex items-center gap-3">
<span className="text-xs font-medium tabular-nums text-[#71717A]">{event.time}</span>
<span className={`text-xs ${severityColorMap[event.severity]}`}>{event.severity}</span>
<Badge variant={severityVariantMap[event.severity]}>{event.severity}</Badge>
</div>
<h4 className="text-sm font-medium text-[#F4F4F5]">{event.title}</h4>
<p className="mt-1 text-sm leading-relaxed text-[#A1A1AA]">{event.detail}</p>
@@ -484,19 +485,18 @@ function Dashboard() {
{/* Risk Factor Frequency */}
<div>
<h3 className="mb-6 text-lg font-medium text-white"></h3>
<div className="mb-6">
<SectionTitle icon={<Tags className="size-4" />} title="主要风险因子分布" />
</div>
<div className="flex flex-wrap gap-2">
{riskFactorCounts.length > 0 ? (
riskFactorCounts.map((item) => (
<div
key={item.factor}
className="flex items-center gap-2 rounded-md border border-white/5 bg-white/[0.02] px-3 py-1.5 text-sm"
>
<span className="text-[#A1A1AA]">{item.factor}</span>
<span className="rounded bg-white/10 px-1.5 py-0.5 text-xs tabular-nums text-white">
<Badge key={item.factor} variant={item.factor.includes('不可用') ? 'warning' : 'default'}>
{item.factor}
<span className="rounded-full bg-white/10 px-1.5 py-0.5 tabular-nums text-white">
{item.count}
</span>
</div>
</Badge>
))
) : (
<div className="text-sm text-[#71717A]"></div>
@@ -514,81 +514,88 @@ function Dashboard() {
<div className="mb-6 flex items-end justify-between">
<div>
<h3 className="text-xl font-medium text-white"></h3>
<p className="mt-1 text-sm text-[#A1A1AA]"> 30/60/90 </p>
<p className="mt-1 text-sm text-[#A1A1AA]">
API 30 90 SoH
</p>
</div>
</div>
<div className="overflow-x-auto rounded-xl border border-white/[0.06] bg-white/[0.02]">
<table className="w-full min-w-[1000px] border-collapse text-left text-sm">
<thead>
<tr className="border-b border-white/10 bg-white/[0.02] text-[#A1A1AA]">
<th className="px-6 py-4 font-medium"></th>
<th className="px-6 py-4 font-medium"></th>
<th className="px-6 py-4 font-medium"> SoH</th>
<th className="px-6 py-4 font-medium">30</th>
<th className="px-6 py-4 font-medium">90</th>
<th className="px-6 py-4 font-medium"></th>
<th className="px-6 py-4 font-medium"></th>
<th className="px-6 py-4 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{devices.length > 0 ? (
devices
.slice()
.sort((a, b) => b.riskScore - a.riskScore)
.map((unit) => (
<tr key={unit.id} className="transition-colors hover:bg-white/[0.04]">
<td className="px-6 py-4 font-medium text-white">{unit.id}</td>
<td className="px-6 py-4 text-[#A1A1AA]">{unit.batch}</td>
<td className="px-6 py-4 tabular-nums text-white">{formatPercentWithUnit(unit.soh)}</td>
<td className="px-6 py-4 tabular-nums text-[#A1A1AA]">{formatPercentWithUnit(unit.soh30d)}</td>
<td className="px-6 py-4 tabular-nums text-[#A1A1AA]">{formatPercentWithUnit(unit.soh90d)}</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<div className="h-1.5 w-12 overflow-hidden rounded-full bg-white/5">
<div
className={`h-full rounded-full ${
unit.riskScore >= 75
? 'bg-red-400'
: unit.riskScore >= 45
? 'bg-amber-400'
: 'bg-emerald-400'
}`}
style={{ width: `${unit.riskScore}%` }}
/>
</div>
<span className="tabular-nums text-white">{unit.riskScore}</span>
</div>
</td>
<td className="px-6 py-4">
<span className={statusColorMap[unit.status]}>{unit.status}</span>
</td>
<td className="px-6 py-4">
<div className="flex flex-wrap gap-1.5">
{unit.riskFactors.length > 0 ? (
unit.riskFactors.map((factor) => (
<span key={factor} className="text-[#A1A1AA]">
{factor}
{factor !== unit.riskFactors[unit.riskFactors.length - 1] && '、'}
</span>
))
) : (
<span className="text-[#71717A]">-</span>
)}
</div>
</td>
</tr>
))
) : (
<tr>
<td colSpan={8} className="px-6 py-8 text-center text-[#71717A]">
</td>
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full min-w-[1000px] border-collapse text-left text-sm">
<thead>
<tr className="border-b border-white/10 bg-white/[0.02] text-zinc-400">
<th className="px-6 py-4 font-medium whitespace-nowrap"></th>
<th className="px-6 py-4 font-medium whitespace-nowrap"></th>
<th className="px-6 py-4 font-medium whitespace-nowrap"> SoH</th>
<th className="px-6 py-4 font-medium whitespace-nowrap">30</th>
<th className="px-6 py-4 font-medium whitespace-nowrap">90</th>
<th className="px-6 py-4 font-medium whitespace-nowrap"></th>
<th className="px-6 py-4 font-medium whitespace-nowrap"></th>
<th className="px-6 py-4 font-medium whitespace-nowrap"></th>
</tr>
)}
</tbody>
</table>
</div>
</thead>
<tbody className="divide-y divide-white/5">
{devices.length > 0 ? (
devices
.slice()
.sort((a, b) => b.riskScore - a.riskScore)
.map((unit) => (
<tr key={unit.id} className="transition-colors hover:bg-white/[0.04]">
<td className="px-6 py-4 font-medium text-white">{unit.id}</td>
<td className="px-6 py-4 text-[#A1A1AA]">{unit.batch}</td>
<td className="px-6 py-4 tabular-nums text-white">{formatPercentWithUnit(unit.soh)}</td>
<td className="px-6 py-4 tabular-nums text-[#A1A1AA]">
{formatPercentWithUnit(unit.soh30d)}
</td>
<td className="px-6 py-4 tabular-nums text-[#A1A1AA]">
{formatPercentWithUnit(unit.soh90d)}
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<div className="h-1.5 w-12 overflow-hidden rounded-full bg-white/5">
<div
className={`h-full rounded-full ${
unit.riskScore >= 75
? 'bg-red-400'
: unit.riskScore >= 45
? 'bg-amber-400'
: 'bg-emerald-400'
}`}
style={{ width: `${unit.riskScore}%` }}
/>
</div>
<span className="tabular-nums text-white">{unit.riskScore}</span>
</div>
</td>
<td className="px-6 py-4">
<Badge variant={statusVariantMap[unit.status]}>{unit.status}</Badge>
</td>
<td className="px-6 py-4">
<div className="flex flex-wrap gap-1.5">
{unit.riskFactors.length > 0 ? (
unit.riskFactors.map((factor) => (
<Badge key={factor} variant={factor.includes('不可用') ? 'warning' : 'default'}>
{factor}
</Badge>
))
) : (
<span className="text-[#71717A]">-</span>
)}
</div>
</td>
</tr>
))
) : (
<tr>
<td colSpan={8} className="px-6 py-8 text-center text-[#71717A]">
</td>
</tr>
)}
</tbody>
</table>
</div>
</Card>
</section>
{/* Bottom Row */}
@@ -622,7 +629,7 @@ function Dashboard() {
{/* Firmware Comparison */}
<div>
<h3 className="mb-6 text-lg font-medium text-white"></h3>
<h3 className="mb-6 text-lg font-medium text-white"></h3>
<div className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-5">
<div className="space-y-4">
{firmwareHealth.length > 0 ? (
+23
View File
@@ -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;
}