Compare commits
2 Commits
4571cee2a1
...
32946b25fa
| Author | SHA1 | Date | |
|---|---|---|---|
| 32946b25fa | |||
| 50e8e32bac |
+2
-2
@@ -1,11 +1,11 @@
|
||||
DATABASE_URL=mysql://user:password@localhost:3306/database
|
||||
|
||||
# Required: external AI SoH prediction service.
|
||||
# 必填:外部 SoH 预测服务地址
|
||||
SOH_PREDICTION_API_BASE_URL=http://127.0.0.1:8000
|
||||
# SOH_PREDICTION_CACHE_TTL_SECONDS=86400
|
||||
# SOH_PREDICTION_TIMEOUT_MS=10000
|
||||
|
||||
# Optional logging knobs (defaults are usually fine):
|
||||
# 可选:日志级别与输出格式
|
||||
# LOG_LEVEL=info # trace|debug|info|warning|error|fatal
|
||||
# LOG_FORMAT=pretty # pretty|json — defaults to TTY ? pretty : json
|
||||
# LOG_DB=false # reserved for database query logging if enabled later
|
||||
|
||||
@@ -33,7 +33,7 @@ Environment variable:
|
||||
DATABASE_URL=mysql://user:password@host:3306/database
|
||||
```
|
||||
|
||||
Optional AI prediction service:
|
||||
Required AI prediction service:
|
||||
|
||||
```bash
|
||||
SOH_PREDICTION_API_BASE_URL=http://127.0.0.1:8000
|
||||
|
||||
@@ -1,31 +1,41 @@
|
||||
# battery-soh
|
||||
|
||||
一个基于 **Bun + TanStack Start + ORPC** 的电池健康展示系统。应用会被打包成单个二进制文件,前端页面、SSR 服务和 ORPC API 一起发布;业务数据只读接入甲方现有 MySQL 表 `ls_battery_info`,本项目不写入、不迁移、不修改甲方数据库。
|
||||
电池健康运营看板,用于接入客户现有设备数据,持续呈现电量状态、健康预测、风险分布与维护建议。系统以只读方式连接客户数据库,不改动生产数据;当健康预测暂不可用时,页面会明确显示不可用状态,避免用误导性数值替代真实结果。
|
||||
|
||||
## 数据源
|
||||
## 产品能力
|
||||
|
||||
甲方提供的只读表结构:
|
||||
- **健康总览**:聚合展示设备规模、平均健康度、30/90 天趋势与预警设备占比。
|
||||
- **风险识别**:按健康度、低电量、充电状态与预测风险生成重点关注设备清单。
|
||||
- **实时明细**:支持按设备名称、编号、电量与充电状态筛选设备,快速定位需要排查的对象。
|
||||
- **维护建议**:基于当前可用数据给出巡检、复查与优先处理建议。
|
||||
- **可信展示**:缺少预测结果时显示“预测不可用”,不会将缺失值渲染为 `0%`。
|
||||
|
||||
| 字段名 | 数据类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `id` | `int(11)` 自增 | 主键 ID |
|
||||
| `user_id` | `int(11)` | 用户 ID |
|
||||
| `mac` | `varchar(50)` | 设备 MAC |
|
||||
| `dev_model` | `varchar(20)` | 设备型号 |
|
||||
| `dev_name` | `varchar(50)` | 设备名称 |
|
||||
| `is_low_power` | `varchar(10)` | 是否低电量:`true` / `false` |
|
||||
| `power_status` | `tinyint(4)` | `0` 未充电,`1` 正在充电,`2` 充电完成 |
|
||||
| `power` | `tinyint(4)` | 当前电量 `0~100` |
|
||||
| `create_time` | `datetime` | 创建时间 |
|
||||
| `remark` | `varchar(500)` | 备注,可空 |
|
||||
## 数据接入
|
||||
|
||||
环境变量:
|
||||
系统接入客户现有 MySQL 数据源,业务运行时仅执行只读查询。需要配置:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=mysql://user:password@host:3306/database
|
||||
```
|
||||
|
||||
AI SoH 预测服务是必填依赖;未配置时应用会在环境变量校验阶段失败,避免把预测缺失误展示为真实 SoH:
|
||||
客户设备表:`ls_battery_info`。
|
||||
|
||||
| 字段名 | 数据类型 | 业务含义 |
|
||||
| --- | --- | --- |
|
||||
| `id` | `int(11)` auto increment | 记录 ID |
|
||||
| `user_id` | `int(11)` | 用户 ID |
|
||||
| `mac` | `varchar(50)` | 设备编号 |
|
||||
| `dev_model` | `varchar(20)` | 设备型号 |
|
||||
| `dev_name` | `varchar(50)` | 设备名称 |
|
||||
| `is_low_power` | `varchar(10)` | 是否低电量:`true` / `false` |
|
||||
| `power_status` | `tinyint(4)` | `0` 未充电,`1` 充电中,`2` 已充满 |
|
||||
| `power` | `tinyint(4)` | 当前电量 `0~100` |
|
||||
| `create_time` | `datetime` | 采集时间 |
|
||||
| `remark` | `varchar(500)` | 备注 |
|
||||
|
||||
## 健康预测
|
||||
|
||||
看板需要接入外部 SoH 预测服务:
|
||||
|
||||
```bash
|
||||
SOH_PREDICTION_API_BASE_URL=http://127.0.0.1:8000
|
||||
@@ -33,7 +43,7 @@ SOH_PREDICTION_CACHE_TTL_SECONDS=86400
|
||||
SOH_PREDICTION_TIMEOUT_MS=10000
|
||||
```
|
||||
|
||||
服务端会向 `${SOH_PREDICTION_API_BASE_URL}/predict` 发起 POST 请求,并把返回的 `now_soh`、`month_soh`、`trmonth_soh`、`risk_score` 等字段用于看板展示。预测结果按设备和最新采集记录做内存 TTL 缓存,默认 24 小时;如果单次预测失败或历史数据不足,对应设备显示“预测不可用”,但不会把缺失值展示成 `0%`。
|
||||
服务端会调用 `${SOH_PREDICTION_API_BASE_URL}/predict`,使用返回的当前健康度、30 天趋势、90 天趋势和风险评分生成看板视图。预测结果会按设备与最新采集记录缓存,默认 24 小时;单台设备预测失败或历史数据不足时,仅该设备显示为“预测不可用”。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -43,53 +53,53 @@ bun install
|
||||
bun run dev
|
||||
```
|
||||
|
||||
如果甲方暂时没有提供数据库连接,可以用 Docker Compose 启动本地 MySQL 并填充示例数据:
|
||||
打开浏览器:
|
||||
|
||||
- `http://localhost:3000/`:设备健康运营看板
|
||||
- `http://localhost:3000/batteries`:设备状态明细
|
||||
- `http://localhost:3000/api/docs`:接口文档
|
||||
|
||||
## 本地演示环境
|
||||
|
||||
如果暂时没有生产数据库连接,可以使用 Docker Compose 启动本地 MySQL 并填充演示数据:
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Compose 会启动三个服务:
|
||||
Compose 会启动:
|
||||
|
||||
- `db`:本地 MySQL 8.4
|
||||
- `seed`:执行 `bun run seed`,用 Drizzle MySQL schema + `drizzle-seed` 重置本地 `ls_battery_info` 并写入示例数据
|
||||
- `app`:使用同一个 `DATABASE_URL` 启动单二进制应用
|
||||
- `seed`:初始化本地 `ls_battery_info` 演示数据
|
||||
- `app`:启动应用服务
|
||||
|
||||
也可以手动 seed 本地库:
|
||||
也可以手动初始化本地数据:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=mysql://battery:battery@localhost:3306/battery_soh bun run seed
|
||||
```
|
||||
|
||||
`seed` 是本地开发/验收脚本,会建表并重置示例数据;应用运行时仍然只执行 `SELECT`,不会写入数据库。
|
||||
`seed` 仅用于本地开发和演示环境;应用运行时仍保持只读查询,不写入业务数据库。
|
||||
|
||||
打开浏览器:
|
||||
## 系统结构
|
||||
|
||||
- `http://localhost:3000/`:SoH 预测与风险洞察看板
|
||||
- `http://localhost:3000/batteries`:设备电池实时状态
|
||||
- `http://localhost:3000/api/docs`:Scalar 渲染的 ORPC OpenAPI 文档
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
```text
|
||||
src/
|
||||
├── routes/ # TanStack Start 文件路由:页面 + API 端点
|
||||
├── routes/ # 页面与 API 路由
|
||||
├── server/
|
||||
│ ├── api/ # ORPC contract / router
|
||||
│ ├── battery/mysql.ts # 甲方 MySQL 只读查询
|
||||
│ └── prediction/client.ts # AI SoH 预测客户端与缓存
|
||||
├── domain/battery.ts # 电池领域类型、归一化、展示聚合
|
||||
├── client/orpc.ts # isomorphic ORPC client
|
||||
└── styles.css # Tailwind v4 entry
|
||||
│ ├── api/ # 接口契约与路由
|
||||
│ ├── battery/mysql.ts # 设备数据只读查询
|
||||
│ └── prediction/client.ts # 健康预测服务客户端
|
||||
├── domain/battery.ts # 电池领域模型与看板聚合
|
||||
├── client/orpc.ts # 前端接口客户端
|
||||
└── styles.css # 全局样式入口
|
||||
```
|
||||
|
||||
接口保持模板里的 ORPC 模式:
|
||||
核心接口:
|
||||
|
||||
- `battery.dashboard`:读取每个 `mac` 的最新记录并生成看板聚合数据
|
||||
- `battery.batteries`:分页返回每台设备最新记录,支持 `pageSize`、`cursor`、`search`、`lowPower`、`powerStatus`、`sort` 筛选/排序
|
||||
- `battery.history`:按 `mac` 返回该设备历史记录,按 `create_time desc` 限制 500 条
|
||||
|
||||
所有业务查询都是 `SELECT`,没有 mutation,也没有 mock/fallback 数据。
|
||||
- `battery.dashboard`:生成健康运营看板数据。
|
||||
- `battery.batteries`:分页返回每台设备的最新状态,支持搜索、筛选和排序。
|
||||
- `battery.history`:返回单台设备最近 500 条历史记录。
|
||||
|
||||
## 部署
|
||||
|
||||
@@ -99,21 +109,21 @@ bun run compile
|
||||
./out/server-<target>
|
||||
```
|
||||
|
||||
Docker 镜像会在构建阶段产出单个 `./server` 二进制,运行阶段只需要配置 `DATABASE_URL`。
|
||||
构建产物为单个服务二进制文件,运行时需要配置 `DATABASE_URL` 与 SoH 预测服务地址。
|
||||
|
||||
## 脚本
|
||||
## 常用命令
|
||||
|
||||
| 命令 | 作用 |
|
||||
| --- | --- |
|
||||
| `bun run dev` | Vite 开发服务器 |
|
||||
| `bun run build` | 构建到 `.output/` |
|
||||
| `bun run compile` | 生成单二进制 `out/server-<target>` |
|
||||
| `bun run seed` | 为本地 MySQL 创建甲方表并填充示例数据 |
|
||||
| `bun run dev` | 启动开发服务 |
|
||||
| `bun run build` | 构建应用 |
|
||||
| `bun run compile` | 生成单二进制产物 |
|
||||
| `bun run seed` | 初始化本地演示数据 |
|
||||
| `bun run typecheck` | TypeScript 类型检查 |
|
||||
| `bun run test` | 运行所有 `*.test.ts` |
|
||||
| `bun run fix` | Biome 格式化 + lint + 整理 imports |
|
||||
| `bun run test` | 运行测试 |
|
||||
| `bun run fix` | 格式化与静态检查 |
|
||||
|
||||
## 验证
|
||||
交付前验证:
|
||||
|
||||
```bash
|
||||
bun run fix && bun run typecheck && bun run test && bun run build
|
||||
|
||||
+10
-10
@@ -251,10 +251,10 @@ function toFleetUnit(item: BatteryInfo, prediction?: BatteryPrediction): FleetUn
|
||||
|
||||
if (item.isLowPower || item.power <= SOH_THRESHOLDS.LOW_POWER) riskFactors.push('低电量')
|
||||
if (item.powerStatus === POWER_STATUS.CHARGING) riskFactors.push('充电中')
|
||||
if (!hasPrediction) riskFactors.push('SoH预测不可用')
|
||||
if (!hasPrediction) riskFactors.push('健康预测不可用')
|
||||
if (prediction && status === DEVICE_STATUS.WARNING) riskFactors.push('衰减加速')
|
||||
if (item.remark?.includes('v3.7')) riskFactors.push('旧固件')
|
||||
if (prediction?.riskLevel) riskFactors.push(`AI风险:${prediction.riskLevel}`)
|
||||
if (prediction?.riskLevel) riskFactors.push(`预测风险:${prediction.riskLevel}`)
|
||||
|
||||
const soh30d = prediction ? round1(clamp(prediction.monthSoh, 0, 100)) : null
|
||||
const soh90d = prediction ? round1(clamp(prediction.trmonthSoh, 0, 100)) : null
|
||||
@@ -392,8 +392,8 @@ function createSummary(devices: FleetUnit[], now: Date) {
|
||||
updatedAt: formatDateTime(now),
|
||||
executiveSummary:
|
||||
avgSoh === null
|
||||
? '当前 AI SoH 预测不可用,页面仅展示 MySQL 采集电量、充电状态与低电量风险。请检查预测服务配置或历史数据量。'
|
||||
: `当前共有 ${predictedDevices} 台设备返回 SoH 预测,${missingPredictionDevices} 台设备暂无预测。重点关注 ${weakestModel} 型号与 ${weakestRemark} 备注设备,优先处理低电量和充电中的设备,并在下次同步后复查缺失预测与未来 30/90 天模型预测。`,
|
||||
? '当前健康预测暂不可用,系统仍会展示设备电量、充电状态与低电量风险。请稍后复查或联系管理员。'
|
||||
: `当前共有 ${predictedDevices} 台设备具备健康预测,${missingPredictionDevices} 台设备暂无预测结果。建议重点关注 ${weakestModel} 型号与 ${weakestRemark} 备注设备,优先处理低电量和充电中的设备,并在下次同步后复查未来 30/90 天健康趋势。`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,7 +408,7 @@ function createEvents(devices: FleetUnit[], now: Date) {
|
||||
{
|
||||
time: formatDateTime(now),
|
||||
title: '风险快照',
|
||||
detail: `本次快照包含 ${devices.length} 台设备,其中 ${predictedDevices.length} 台返回真实 SoH 预测,${warningDevices.length} 台处于预警状态。`,
|
||||
detail: `本次概览包含 ${devices.length} 台设备,其中 ${predictedDevices.length} 台具备健康预测,${warningDevices.length} 台处于预警状态。`,
|
||||
severity: warningDevices.length > 0 ? EVENT_SEVERITY.HIGH : EVENT_SEVERITY.LOW,
|
||||
},
|
||||
{
|
||||
@@ -416,8 +416,8 @@ function createEvents(devices: FleetUnit[], now: Date) {
|
||||
title: '预测可用性快照',
|
||||
detail:
|
||||
missingPredictionDevices > 0
|
||||
? `当前有 ${missingPredictionDevices} 台设备暂无 SoH 预测,对应图表与卡片保留为空值。`
|
||||
: '当前所有设备均已返回 SoH 预测,图表仅展示真实预测点。',
|
||||
? `当前有 ${missingPredictionDevices} 台设备暂无健康预测,相关趋势将暂不展示。`
|
||||
: '当前所有设备均已具备健康预测,可继续观察趋势变化。',
|
||||
severity: missingPredictionDevices > 0 ? EVENT_SEVERITY.MEDIUM : EVENT_SEVERITY.LOW,
|
||||
},
|
||||
] satisfies DashboardSnapshot['events']
|
||||
@@ -440,11 +440,11 @@ function createStrategies(devices: FleetUnit[]) {
|
||||
eta: '本次巡检周期内',
|
||||
},
|
||||
{
|
||||
name: '补齐预测覆盖',
|
||||
name: '提升预测覆盖',
|
||||
impact:
|
||||
missingPredictionDevices.length > 0
|
||||
? `当前有 ${missingPredictionDevices.length} 台设备暂无 SoH 预测,建议在下次同步后复查。`
|
||||
: `当前已有 ${devices.length} 台设备返回预测结果,可继续观察真实变化。`,
|
||||
? `当前有 ${missingPredictionDevices.length} 台设备暂无健康预测,建议在下次同步后复查。`
|
||||
: `当前已有 ${devices.length} 台设备具备预测结果,可继续观察健康变化。`,
|
||||
scope:
|
||||
powerAttentionDevices.length > 0
|
||||
? `${powerAttentionDevices.length} 台充电中或低电量设备`
|
||||
|
||||
+14
-12
@@ -1,7 +1,7 @@
|
||||
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 { ArrowLeft, Battery, BatteryCharging, BatteryLow, FilterX, Search, Zap } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
import { orpc } from '@/client/orpc'
|
||||
@@ -77,6 +77,8 @@ function parseSort(value: string): BatteryListSort {
|
||||
}
|
||||
|
||||
function parsePowerStatus(value: string): PowerStatus | undefined {
|
||||
if (value === '') return undefined
|
||||
|
||||
const parsed = Number(value)
|
||||
|
||||
return POWER_STATUS_VALUES.find((option) => option === parsed)
|
||||
@@ -242,11 +244,11 @@ function BatteriesPage() {
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<Badge variant="info" className="mb-4">
|
||||
<Database className="size-3.5" /> MySQL 实时记录
|
||||
<Battery className="size-3.5" /> 实时设备数据
|
||||
</Badge>
|
||||
<h1 className="text-3xl font-light tracking-tight text-white">设备电池实时状态</h1>
|
||||
<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')}` : '加载中…'}
|
||||
@@ -257,7 +259,7 @@ function BatteriesPage() {
|
||||
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 看板
|
||||
<ArrowLeft className="size-4" /> 返回健康看板
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -290,7 +292,7 @@ function BatteriesPage() {
|
||||
<SectionTitle
|
||||
icon={<Search className="size-4" />}
|
||||
title="筛选设备"
|
||||
description="按真实采集字段筛选,不在前端伪造或补齐记录。"
|
||||
description="按设备名称、编号、电量与充电状态快速缩小排查范围。"
|
||||
/>
|
||||
{hasActiveFilters && (
|
||||
<Button type="button" className="h-9 px-3 text-xs" onClick={clearFilters}>
|
||||
@@ -301,14 +303,14 @@ function BatteriesPage() {
|
||||
<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..."
|
||||
placeholder="搜索设备名称或编号..."
|
||||
maxLength={100}
|
||||
className="pl-9"
|
||||
value={localSearch}
|
||||
@@ -369,7 +371,7 @@ function BatteriesPage() {
|
||||
|
||||
<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"
|
||||
@@ -411,7 +413,7 @@ function BatteriesPage() {
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Zap className="size-4 text-amber-300" /> 仅看低电量
|
||||
<Zap className="size-4 text-amber-300" /> 仅显示低电量设备
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -440,7 +442,7 @@ function BatteriesPage() {
|
||||
) : data?.items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="h-32 text-center text-zinc-500">
|
||||
暂无匹配设备
|
||||
未找到符合条件的设备
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
@@ -461,7 +463,7 @@ function BatteriesPage() {
|
||||
|
||||
<div className="mt-6 flex items-center justify-between text-sm text-zinc-500">
|
||||
<div>
|
||||
显示 {data?.items.length ?? 0} 台设备
|
||||
当前显示 {data?.items.length ?? 0} 台设备
|
||||
{data?.total ? ` (共 ${data.total} 台)` : ''}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
+22
-24
@@ -1,6 +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 { Activity, AlertTriangle, ArrowRight, ShieldCheck, Tags, TrendingDown } from 'lucide-react'
|
||||
import {
|
||||
Area,
|
||||
CartesianGrid,
|
||||
@@ -78,7 +78,7 @@ function formatChartTooltip(value: ValueType | undefined, name: NameType | undef
|
||||
|
||||
return [
|
||||
`${Number.isFinite(numericValue) ? numericValue.toFixed(1) : (value ?? '-')}%`,
|
||||
name === 'history' ? '历史观测' : '模型预测',
|
||||
name === 'history' ? '历史观测' : '趋势预测',
|
||||
]
|
||||
}
|
||||
|
||||
@@ -150,12 +150,12 @@ function Dashboard() {
|
||||
<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">
|
||||
<Badge variant="info" className="mb-4">
|
||||
<Database className="size-3.5" /> MySQL 实时记录 + AI 预测 API
|
||||
<Activity className="size-3.5" /> 实时数据与健康预测
|
||||
</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">电池健康与风险洞察</h1>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-3 text-right">
|
||||
<Badge variant="muted">仅展示真实记录与预测接口返回值</Badge>
|
||||
<Badge variant="muted">基于当前可用数据生成</Badge>
|
||||
<p className="text-xs tabular-nums text-[#71717A]">数据更新时间: {updatedAt}</p>
|
||||
<Link
|
||||
to="/batteries"
|
||||
@@ -178,7 +178,7 @@ function Dashboard() {
|
||||
{/* Hero KPI */}
|
||||
<article className="relative overflow-hidden rounded-2xl border border-white/10 bg-white/[0.03] p-8 lg:col-span-2">
|
||||
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-teal-500/50 to-transparent" />
|
||||
<p className="text-sm font-medium text-[#A1A1AA]">当前平均 SoH</p>
|
||||
<p className="text-sm font-medium text-[#A1A1AA]">当前平均健康度</p>
|
||||
<div className="mt-4 flex items-baseline gap-2">
|
||||
<h2 className="text-6xl font-light tabular-nums text-white">{formatPercent(avgSoh)}</h2>
|
||||
{avgSoh !== null && <span className="text-2xl text-[#71717A]">%</span>}
|
||||
@@ -186,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 ? '健康预测暂不可用' : '预测已返回'}
|
||||
</span>
|
||||
<span className="text-[#71717A]">|</span>
|
||||
<span className="text-[#A1A1AA]">共 {totalDevices} 台设备</span>
|
||||
@@ -236,14 +236,14 @@ function Dashboard() {
|
||||
{/* Divider */}
|
||||
<hr className="my-12 border-white/5" />
|
||||
|
||||
{/* SoH Trend Chart */}
|
||||
{/* Health trend chart */}
|
||||
<section className="animate-fade-up delay-300 mb-12">
|
||||
<Card className="p-8">
|
||||
<header className="mb-8 flex flex-wrap items-end justify-between gap-4">
|
||||
<SectionTitle
|
||||
icon={<TrendingDown className="size-4" />}
|
||||
title="SoH 预测点位"
|
||||
description="图表只展示 AI 预测 API 返回的当前、30 天、90 天聚合点;没有真实 SoH 历史时不补假趋势。"
|
||||
title="健康趋势预测"
|
||||
description="展示当前健康度与未来 30/90 天趋势;数据不足时保持空态,避免误导判断。"
|
||||
/>
|
||||
<div className="flex items-center gap-6 text-sm text-[#A1A1AA]">
|
||||
{soh.history.length > 0 && (
|
||||
@@ -254,7 +254,7 @@ function Dashboard() {
|
||||
)}
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="h-2 w-4 rounded-full bg-indigo-400" />
|
||||
API 预测值
|
||||
预测趋势
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
@@ -354,7 +354,7 @@ function Dashboard() {
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center rounded-xl border border-dashed border-white/10 bg-white/[0.02] text-sm text-[#71717A]">
|
||||
AI SoH 预测不可用,暂无可绘制的健康度趋势。
|
||||
暂无可用的健康趋势数据。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -368,12 +368,12 @@ function Dashboard() {
|
||||
{/* Risk Distribution */}
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<SectionTitle icon={<ShieldCheck className="size-4" />} title="风险分层与结构" />
|
||||
<SectionTitle icon={<ShieldCheck className="size-4" />} title="健康分布" />
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<div className="mb-2 flex justify-between text-sm">
|
||||
<span className="text-[#A1A1AA]">健康 (SoH > 90%)</span>
|
||||
<span className="text-[#A1A1AA]">健康 (> 90%)</span>
|
||||
<span className="tabular-nums text-white">
|
||||
{healthyCount} 台{' '}
|
||||
<span className="text-[#71717A]">
|
||||
@@ -390,7 +390,7 @@ function Dashboard() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex justify-between text-sm">
|
||||
<span className="text-[#A1A1AA]">关注 (85% < SoH ≤ 90%)</span>
|
||||
<span className="text-[#A1A1AA]">关注 (85% - 90%)</span>
|
||||
<span className="tabular-nums text-white">
|
||||
{watchCount} 台{' '}
|
||||
<span className="text-[#71717A]">
|
||||
@@ -407,7 +407,7 @@ function Dashboard() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex justify-between text-sm">
|
||||
<span className="text-[#A1A1AA]">预警 (SoH ≤ 85%)</span>
|
||||
<span className="text-[#A1A1AA]">预警 (≤ 85%)</span>
|
||||
<span className="tabular-nums text-white">
|
||||
{warningCount} 台{' '}
|
||||
<span className="text-[#71717A]">
|
||||
@@ -458,7 +458,7 @@ function Dashboard() {
|
||||
{/* Event Timeline */}
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<SectionTitle icon={<AlertTriangle className="size-4" />} title="风险与预测快照" />
|
||||
<SectionTitle icon={<AlertTriangle className="size-4" />} title="风险与趋势概览" />
|
||||
</div>
|
||||
<div className="relative border-l border-white/10 pl-5">
|
||||
{events.length > 0 ? (
|
||||
@@ -513,10 +513,8 @@ function Dashboard() {
|
||||
<section className="animate-fade-up delay-500 mb-12">
|
||||
<div className="mb-6 flex items-end justify-between">
|
||||
<div>
|
||||
<h3 className="text-xl font-medium text-white">高风险设备清单与预测明细</h3>
|
||||
<p className="mt-1 text-sm text-[#A1A1AA]">
|
||||
按综合风险评分排序,展示 API 返回的当前、30 天与 90 天 SoH 预测
|
||||
</p>
|
||||
<h3 className="text-xl font-medium text-white">重点关注设备</h3>
|
||||
<p className="mt-1 text-sm text-[#A1A1AA]">按风险优先级展示当前健康度与未来 30/90 天趋势。</p>
|
||||
</div>
|
||||
</div>
|
||||
<Card className="overflow-hidden">
|
||||
@@ -526,7 +524,7 @@ function Dashboard() {
|
||||
<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">当前健康度</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>
|
||||
@@ -602,7 +600,7 @@ function Dashboard() {
|
||||
<section className="animate-fade-up delay-500 grid grid-cols-1 gap-8 lg:grid-cols-3">
|
||||
{/* Strategy Cards */}
|
||||
<div className="lg:col-span-2">
|
||||
<h3 className="mb-6 text-lg font-medium text-white">模型驱动的维护策略建议</h3>
|
||||
<h3 className="mb-6 text-lg font-medium text-white">行动建议</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{strategies.length > 0 ? (
|
||||
strategies.map((item, index) => (
|
||||
@@ -641,7 +639,7 @@ function Dashboard() {
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm tabular-nums text-white">{formatPercentWithUnit(item.avgSoh)}</div>
|
||||
<div className="text-xs text-[#71717A]">平均 SoH</div>
|
||||
<div className="text-xs text-[#71717A]">平均健康度</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
Reference in New Issue
Block a user