Compare commits

..

2 Commits

Author SHA1 Message Date
imbytecat 32946b25fa docs: 优化交付文档表达 2026-05-12 00:18:00 +08:00
imbytecat 50e8e32bac fix(ui): 修复充电状态筛选重置 2026-05-12 00:18:00 +08:00
6 changed files with 113 additions and 103 deletions
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+64 -54
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 &gt; 90%)</span>
<span className="text-[#A1A1AA]"> (&gt; 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% &lt; SoH &le; 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 &le; 85%)</span>
<span className="text-[#A1A1AA]"> (&le; 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>
))