Compare commits

...

5 Commits

14 changed files with 472 additions and 262 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
DATABASE_URL=mysql://user:password@localhost:3306/database
# Optional: external AI SoH prediction service.
# SOH_PREDICTION_API_BASE_URL=http://127.0.0.1:8000
# Required: external AI SoH prediction service.
SOH_PREDICTION_API_BASE_URL=http://127.0.0.1:8000
# SOH_PREDICTION_CACHE_TTL_SECONDS=86400
# SOH_PREDICTION_TIMEOUT_MS=10000
+1 -1
View File
@@ -64,7 +64,7 @@ Rules:
- `power_status` is normalized to `0 | 1 | 2`.
- `battery.batteries` returns paginated latest records per `mac`; supported filters are `pageSize`, `cursor`, `search`, `lowPower`, `powerStatus`, and `sort`.
- `battery.history` takes `mac` and returns history ordered by `create_time DESC, id DESC`, limited to 500 rows.
- Dashboard may call the external prediction API when `SOH_PREDICTION_API_BASE_URL` is configured. Prediction results are cached in memory by `mac` and latest history record.
- Dashboard requires the external prediction API via `SOH_PREDICTION_API_BASE_URL`; missing configuration must fail environment validation. Per-device prediction failures may surface as unavailable values, but must not be rendered as `0%`.
## Layout
+2 -2
View File
@@ -25,7 +25,7 @@
DATABASE_URL=mysql://user:password@host:3306/database
```
可选 AI SoH 预测服务:
AI SoH 预测服务是必填依赖;未配置时应用会在环境变量校验阶段失败,避免把预测缺失误展示为真实 SoH
```bash
SOH_PREDICTION_API_BASE_URL=http://127.0.0.1:8000
@@ -33,7 +33,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 小时;如果未配置或预测服务失败,看板仍使用 MySQL 采集数据生成展示,不写入数据库
服务端会向 `${SOH_PREDICTION_API_BASE_URL}/predict` 发起 POST 请求,并把返回的 `now_soh``month_soh``trmonth_soh``risk_score` 等字段用于看板展示。预测结果按设备和最新采集记录做内存 TTL 缓存,默认 24 小时;如果单次预测失败或历史数据不足,对应设备显示“预测不可用”,但不会把缺失值展示成 `0%`
## 快速开始
+3
View File
@@ -26,6 +26,7 @@ services:
condition: service_healthy
environment:
- DATABASE_URL=mysql://battery:battery@db:3306/battery_soh
- SOH_PREDICTION_API_BASE_URL=http://host.docker.internal:8000
command: [ "bun", "run", "seed" ]
app:
@@ -33,6 +34,8 @@ services:
depends_on:
seed:
condition: service_completed_successfully
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "3000:3000"
environment:
+35 -13
View File
@@ -2,14 +2,15 @@ import { datetime, index, int, mysqlTable, tinyint, varchar } from 'drizzle-orm/
import { drizzle } from 'drizzle-orm/mysql2'
import { reset } from 'drizzle-seed'
import mysql from 'mysql2/promise'
import { type MYSQL_BOOLEAN, POWER_STATUS, type PowerStatus, toMysqlBoolean } from '@/domain/battery'
type SeedRow = {
userId: number
mac: string
devModel: string
devName: string
isLowPower: 'true' | 'false'
powerStatus: 0 | 1 | 2
isLowPower: (typeof MYSQL_BOOLEAN)[keyof typeof MYSQL_BOOLEAN]
powerStatus: PowerStatus
power: number
createTime: Date
remark: string | null
@@ -48,20 +49,41 @@ if (!safeSeedHosts.has(parsedUrl.hostname) && process.env.SEED_ALLOW_REMOTE !==
}
const devices = [
{ mac: 'RING-A03', model: 'SR-01', name: '样机-A03', basePower: 96, status: 2, remark: 'v3.8.2' },
{ mac: 'RING-B11', model: 'SR-01', name: '样机-B11', basePower: 91, status: 1, remark: 'v3.8.2' },
{ mac: 'RING-C07', model: 'SR-02', name: '样机-C07', basePower: 88, status: 0, remark: 'v3.8.1' },
{ mac: 'RING-D19', model: 'SR-02', name: '样机-D19', basePower: 84, status: 0, remark: 'v3.7.9' },
{ mac: 'RING-E21', model: 'SR-03', name: '样机-E21', basePower: 79, status: 1, remark: 'v3.7.9' },
{ mac: 'RING-F02', model: 'SR-03', name: '样机-F02', basePower: 73, status: 0, remark: null },
{ mac: 'RING-G15', model: 'SR-04', name: '样机-G15', basePower: 93, status: 2, remark: 'v3.9.0' },
{ mac: 'RING-H09', model: 'SR-04', name: '样机-H09', basePower: 86, status: 0, remark: 'v3.8.1' },
{ mac: 'RING-A03', model: 'SR-01', name: '样机-A03', basePower: 96, status: POWER_STATUS.FULL, remark: 'v3.8.2' },
{ mac: 'RING-B11', model: 'SR-01', name: '样机-B11', basePower: 91, status: POWER_STATUS.CHARGING, remark: 'v3.8.2' },
{
mac: 'RING-C07',
model: 'SR-02',
name: '样机-C07',
basePower: 88,
status: POWER_STATUS.NOT_CHARGING,
remark: 'v3.8.1',
},
{
mac: 'RING-D19',
model: 'SR-02',
name: '样机-D19',
basePower: 84,
status: POWER_STATUS.NOT_CHARGING,
remark: 'v3.7.9',
},
{ mac: 'RING-E21', model: 'SR-03', name: '样机-E21', basePower: 79, status: POWER_STATUS.CHARGING, remark: 'v3.7.9' },
{ mac: 'RING-F02', model: 'SR-03', name: '样机-F02', basePower: 73, status: POWER_STATUS.NOT_CHARGING, remark: null },
{ mac: 'RING-G15', model: 'SR-04', name: '样机-G15', basePower: 93, status: POWER_STATUS.FULL, remark: 'v3.9.0' },
{
mac: 'RING-H09',
model: 'SR-04',
name: '样机-H09',
basePower: 86,
status: POWER_STATUS.NOT_CHARGING,
remark: 'v3.8.1',
},
] satisfies Array<{
mac: string
model: string
name: string
basePower: number
status: 0 | 1 | 2
status: PowerStatus
remark: string | null
}>
@@ -76,8 +98,8 @@ function createSeedRows(now = new Date()): SeedRow[] {
mac: device.mac,
devModel: device.model,
devName: device.name,
isLowPower: power <= 20 || device.basePower <= 80 ? 'true' : 'false',
powerStatus: historyIndex === 0 ? device.status : 0,
isLowPower: toMysqlBoolean(power <= 20 || device.basePower <= 80),
powerStatus: historyIndex === 0 ? device.status : POWER_STATUS.NOT_CHARGING,
power,
createTime: createdAt,
remark: device.remark,
+63
View File
@@ -0,0 +1,63 @@
export const POWER_STATUS = {
NOT_CHARGING: 0,
CHARGING: 1,
FULL: 2,
} as const
export type PowerStatus = (typeof POWER_STATUS)[keyof typeof POWER_STATUS]
export const POWER_STATUS_VALUES = [POWER_STATUS.NOT_CHARGING, POWER_STATUS.CHARGING, POWER_STATUS.FULL] as const
export const BATTERY_LIST_SORT = {
CREATED_AT_DESC: 'createdAtDesc',
CREATED_AT_ASC: 'createdAtAsc',
POWER_DESC: 'powerDesc',
POWER_ASC: 'powerAsc',
} as const
export type BatteryListSort = (typeof BATTERY_LIST_SORT)[keyof typeof BATTERY_LIST_SORT]
export const BATTERY_LIST_SORT_VALUES = [
BATTERY_LIST_SORT.CREATED_AT_DESC,
BATTERY_LIST_SORT.CREATED_AT_ASC,
BATTERY_LIST_SORT.POWER_DESC,
BATTERY_LIST_SORT.POWER_ASC,
] as const
export const MYSQL_BOOLEAN = {
TRUE: 'true',
FALSE: 'false',
} as const
export function toMysqlBoolean(value: boolean) {
return value ? MYSQL_BOOLEAN.TRUE : MYSQL_BOOLEAN.FALSE
}
export function fromMysqlBoolean(value: string | boolean) {
if (typeof value === 'boolean') return value
return value.toLowerCase() === MYSQL_BOOLEAN.TRUE
}
export const DEVICE_STATUS = {
HEALTHY: '健康',
WATCH: '关注',
WARNING: '预警',
} as const
export type DeviceStatus = (typeof DEVICE_STATUS)[keyof typeof DEVICE_STATUS]
export const EVENT_SEVERITY = {
HIGH: '高',
MEDIUM: '中',
LOW: '低',
} as const
export type EventSeverity = (typeof EVENT_SEVERITY)[keyof typeof EVENT_SEVERITY]
export const SOH_THRESHOLDS = {
WARNING: 85,
WATCH: 90,
LOW_POWER: 20,
HIGH_RISK_SCORE: 70,
WATCH_RISK_SCORE: 40,
} as const
+25 -12
View File
@@ -1,5 +1,13 @@
import { describe, expect, test } from 'bun:test'
import { createBatteriesResponse, createDashboardSnapshot, getDeviceStatus, toBatteryInfo } from './battery'
import {
createBatteriesResponse,
createDashboardSnapshot,
DEVICE_STATUS,
getDeviceStatus,
MYSQL_BOOLEAN,
POWER_STATUS,
toBatteryInfo,
} from './battery'
const rows = [
{
@@ -8,8 +16,8 @@ const rows = [
mac: 'RING-A03',
devModel: '2401-A',
devName: 'RING-A03',
isLowPower: 'false',
powerStatus: 2,
isLowPower: MYSQL_BOOLEAN.FALSE,
powerStatus: POWER_STATUS.FULL,
power: 94,
createTime: new Date('2026-05-10T23:00:00.000Z'),
remark: 'v3.8.2',
@@ -20,8 +28,8 @@ const rows = [
mac: 'RING-B11',
devModel: '2402-B',
devName: 'RING-B11',
isLowPower: 'true',
powerStatus: 1,
isLowPower: MYSQL_BOOLEAN.TRUE,
powerStatus: POWER_STATUS.CHARGING,
power: 84,
createTime: '2026-05-10 22:00:00',
remark: null,
@@ -30,9 +38,9 @@ const rows = [
describe('battery domain', () => {
test('preserves legacy SOH status thresholds', () => {
expect(getDeviceStatus(91)).toBe('健康')
expect(getDeviceStatus(90)).toBe('关注')
expect(getDeviceStatus(85)).toBe('预警')
expect(getDeviceStatus(91)).toBe(DEVICE_STATUS.HEALTHY)
expect(getDeviceStatus(90)).toBe(DEVICE_STATUS.WATCH)
expect(getDeviceStatus(85)).toBe(DEVICE_STATUS.WARNING)
})
test('builds batteries response counters from records', () => {
@@ -54,10 +62,15 @@ describe('battery domain', () => {
expect(snapshot.devices).toHaveLength(2)
expect(snapshot.devices.every((device) => device.sohSource === 'unavailable')).toBe(true)
expect(snapshot.devices.every((device) => device.soh === 0)).toBe(true)
expect(snapshot.soh.history).toHaveLength(12)
expect(snapshot.soh.forecast).toHaveLength(4)
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.soh.history).toHaveLength(0)
expect(snapshot.soh.forecast).toHaveLength(0)
expect(snapshot.summary.totalDevices).toBe(snapshot.devices.length)
expect(snapshot.summary.avgSoh).toBeNull()
expect(snapshot.summary.avgSoh30d).toBeNull()
expect(snapshot.summary.avgSoh90d).toBeNull()
expect(snapshot.summary.warningCount + snapshot.summary.watchCount + snapshot.summary.healthyCount).toBe(
snapshot.devices.length,
)
@@ -93,7 +106,7 @@ describe('battery domain', () => {
expect(predicted?.sohSource).toBe('prediction')
expect(predicted?.soh30d).toBe(58)
expect(predicted?.soh90d).toBe(52)
expect(predicted?.status).toBe('预警')
expect(predicted?.status).toBe(DEVICE_STATUS.WARNING)
expect(predicted?.firmware).toBe('XGBoost')
})
})
+123 -63
View File
@@ -1,10 +1,42 @@
import { z } from 'zod'
import {
DEVICE_STATUS,
type DeviceStatus,
EVENT_SEVERITY,
fromMysqlBoolean,
POWER_STATUS,
type PowerStatus,
SOH_THRESHOLDS,
} from './battery.constants'
export const powerStatusSchema = z.union([z.literal(0), z.literal(1), z.literal(2)])
export type PowerStatus = z.infer<typeof powerStatusSchema>
export {
BATTERY_LIST_SORT,
BATTERY_LIST_SORT_VALUES,
type BatteryListSort,
DEVICE_STATUS,
type DeviceStatus,
EVENT_SEVERITY,
type EventSeverity,
fromMysqlBoolean,
MYSQL_BOOLEAN,
POWER_STATUS,
POWER_STATUS_VALUES,
type PowerStatus,
SOH_THRESHOLDS,
toMysqlBoolean,
} from './battery.constants'
export const deviceStatusSchema = z.union([z.literal('健康'), z.literal('关注'), z.literal('预警')])
export type DeviceStatus = z.infer<typeof deviceStatusSchema>
export const powerStatusSchema = z.union([
z.literal(POWER_STATUS.NOT_CHARGING),
z.literal(POWER_STATUS.CHARGING),
z.literal(POWER_STATUS.FULL),
])
export const deviceStatusSchema = z.union([
z.literal(DEVICE_STATUS.HEALTHY),
z.literal(DEVICE_STATUS.WATCH),
z.literal(DEVICE_STATUS.WARNING),
])
export const batteryInfoSchema = z.object({
id: z.number().int(),
@@ -25,11 +57,11 @@ export const fleetUnitSchema = z.object({
batch: z.string(),
firmware: z.string(),
cycles: z.number().int(),
soh: z.number(),
soh: z.number().nullable(),
sohSource: z.union([z.literal('prediction'), z.literal('unavailable')]),
soh30d: z.number(),
soh60d: z.number(),
soh90d: z.number(),
soh30d: z.number().nullable(),
soh60d: z.number().nullable(),
soh90d: z.number().nullable(),
temperature: z.number(),
riskScore: z.number().int(),
chargeEfficiency: z.number(),
@@ -48,7 +80,7 @@ export const eventItemSchema = z.object({
time: z.string(),
title: z.string(),
detail: z.string(),
severity: z.union([z.literal('高'), z.literal('中'), z.literal('低')]),
severity: z.union([z.literal(EVENT_SEVERITY.HIGH), z.literal(EVENT_SEVERITY.MEDIUM), z.literal(EVENT_SEVERITY.LOW)]),
})
export const strategyItemSchema = z.object({
@@ -60,15 +92,15 @@ export const strategyItemSchema = z.object({
export const summaryResponseSchema = z.object({
totalDevices: z.number().int(),
avgSoh: z.number(),
avgSoh30d: z.number(),
avgSoh90d: z.number(),
avgSoh: z.number().nullable(),
avgSoh30d: z.number().nullable(),
avgSoh90d: z.number().nullable(),
warningCount: z.number().int(),
watchCount: z.number().int(),
healthyCount: z.number().int(),
batchPerformance: z.array(z.object({ batch: z.string(), avgSoh: z.number() })),
batchPerformance: z.array(z.object({ batch: z.string(), avgSoh: z.number().nullable() })),
riskFactorCounts: z.array(z.object({ factor: z.string(), count: z.number().int() })),
firmwareHealth: z.array(z.object({ firmware: z.string(), avgSoh: z.number(), count: z.number().int() })),
firmwareHealth: z.array(z.object({ firmware: z.string(), avgSoh: z.number().nullable(), count: z.number().int() })),
updatedAt: z.string(),
executiveSummary: z.string(),
})
@@ -131,9 +163,9 @@ 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 <= 85) return '预警'
if (soh <= 90) return '关注'
return '健康'
if (soh <= SOH_THRESHOLDS.WARNING) return DEVICE_STATUS.WARNING
if (soh <= SOH_THRESHOLDS.WATCH) return DEVICE_STATUS.WATCH
return DEVICE_STATUS.HEALTHY
}
function getDeviceStatusByRisk(prediction: BatteryPrediction): DeviceStatus {
@@ -145,7 +177,7 @@ function getDeviceStatusByRisk(prediction: BatteryPrediction): DeviceStatus {
riskText.includes('危险') ||
riskText.includes('高')
) {
return '预警'
return DEVICE_STATUS.WARNING
}
if (
riskText.includes('medium') ||
@@ -153,25 +185,24 @@ function getDeviceStatusByRisk(prediction: BatteryPrediction): DeviceStatus {
riskText.includes('关注') ||
riskText.includes('中')
) {
return '关注'
return DEVICE_STATUS.WATCH
}
if (prediction.riskScore !== null) {
if (prediction.riskScore >= 70) return '预警'
if (prediction.riskScore >= 40) return '关注'
if (prediction.riskScore >= SOH_THRESHOLDS.HIGH_RISK_SCORE) return DEVICE_STATUS.WARNING
if (prediction.riskScore >= SOH_THRESHOLDS.WATCH_RISK_SCORE) return DEVICE_STATUS.WATCH
}
return getDeviceStatus(prediction.nowSoh)
}
export function normalizePowerStatus(value: number): PowerStatus {
if (value === 1 || value === 2) return value
return 0
if (value === POWER_STATUS.CHARGING || value === POWER_STATUS.FULL) return value
return POWER_STATUS.NOT_CHARGING
}
export function normalizeLowPower(value: string | boolean): boolean {
if (typeof value === 'boolean') return value
return value.toLowerCase() === 'true'
return fromMysqlBoolean(value)
}
export type BatteryInfoSourceRow = {
@@ -212,7 +243,7 @@ export function createBatteriesResponse(
updatedAt: now.toISOString(),
total: summary.total ?? items.length,
lowPower: summary.lowPower ?? items.filter((item) => item.isLowPower).length,
charging: summary.charging ?? items.filter((item) => item.powerStatus === 1).length,
charging: summary.charging ?? items.filter((item) => item.powerStatus === POWER_STATUS.CHARGING).length,
items,
nextCursor,
}
@@ -220,21 +251,25 @@ export function createBatteriesResponse(
function toFleetUnit(item: BatteryInfo, index: number, prediction?: BatteryPrediction): FleetUnit {
const hasPrediction = Boolean(prediction)
const soh = prediction ? round1(clamp(prediction.nowSoh, 0, 100)) : 0
const status = prediction ? getDeviceStatusByRisk(prediction) : item.isLowPower || item.power <= 20 ? '关注' : '健康'
const soh = prediction ? round1(clamp(prediction.nowSoh, 0, 100)) : null
const status = prediction
? getDeviceStatusByRisk(prediction)
: item.isLowPower || item.power <= SOH_THRESHOLDS.LOW_POWER
? DEVICE_STATUS.WATCH
: DEVICE_STATUS.HEALTHY
const riskFactors: string[] = []
if (item.isLowPower || item.power <= 20) riskFactors.push('低电量')
if (item.powerStatus === 1) riskFactors.push('充电中')
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 (prediction && status === '预警') 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}`)
const thermalPressure = index % 3
const soh30d = prediction ? round1(clamp(prediction.monthSoh, 0, 100)) : 0
const soh90d = prediction ? round1(clamp(prediction.trmonthSoh, 0, 100)) : 0
const soh60d = prediction ? round1((soh30d + soh90d) / 2) : 0
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(
@@ -249,7 +284,7 @@ function toFleetUnit(item: BatteryInfo, index: number, prediction?: BatteryPredi
id: item.devName || item.mac,
batch: item.devModel,
firmware: prediction?.modelName ?? item.remark ?? 'unknown',
cycles: 120 + index * 17 + Math.round((100 - soh) * 2.2),
cycles: 120 + index * 17 + Math.round((100 - (soh ?? item.power)) * 2.2),
soh,
sohSource: prediction ? 'prediction' : 'unavailable',
soh30d,
@@ -264,10 +299,12 @@ function toFleetUnit(item: BatteryInfo, index: number, prediction?: BatteryPredi
}
function createSohResponse(devices: FleetUnit[], now: Date) {
if (devices.length === 0) return { history: [], forecast: [] }
const predictedDevices = devices.filter((unit) => unit.soh !== null)
if (predictedDevices.length === 0) return { history: [], forecast: [] }
const avgSoh = devices.reduce((sum, unit) => sum + unit.soh, 0) / devices.length
const monthlyDrop = 0.45 + devices.reduce((sum, unit) => sum + unit.riskScore, 0) / devices.length / 160
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 history = Array.from({ length: 12 }, (_, index) => {
const monthOffset = index - 11
@@ -287,10 +324,13 @@ function createSohResponse(devices: FleetUnit[], now: Date) {
function summarizeBy<T extends string>(items: FleetUnit[], getKey: (item: FleetUnit) => T) {
return Object.entries(
items.reduce<Record<string, { sum: number; count: number }>>((acc, item) => {
items.reduce<Record<string, { sum: number; valueCount: number; count: number }>>((acc, item) => {
const key = getKey(item)
const entry = acc[key] ?? { sum: 0, count: 0 }
entry.sum += item.soh
const entry = acc[key] ?? { sum: 0, valueCount: 0, count: 0 }
if (item.soh !== null) {
entry.sum += item.soh
entry.valueCount += 1
}
entry.count += 1
acc[key] = entry
return acc
@@ -298,15 +338,22 @@ function summarizeBy<T extends string>(items: FleetUnit[], getKey: (item: FleetU
)
}
function averageNullable(values: Array<number | null>) {
const available = values.filter((value) => value !== null)
if (available.length === 0) return null
return available.reduce((sum, value) => sum + value, 0) / available.length
}
function createSummary(devices: FleetUnit[], now: Date) {
const totalDevices = devices.length
if (totalDevices === 0) {
return {
totalDevices,
avgSoh: 0,
avgSoh30d: 0,
avgSoh90d: 0,
avgSoh: null,
avgSoh30d: null,
avgSoh90d: null,
warningCount: 0,
watchCount: 0,
healthyCount: 0,
@@ -318,18 +365,22 @@ function createSummary(devices: FleetUnit[], now: Date) {
}
}
const avgSoh = devices.reduce((sum, unit) => sum + unit.soh, 0) / totalDevices
const avgSoh30d = devices.reduce((sum, unit) => sum + unit.soh30d, 0) / totalDevices
const avgSoh90d = devices.reduce((sum, unit) => sum + unit.soh90d, 0) / totalDevices
const warningCount = devices.filter((unit) => unit.status === '预警').length
const watchCount = devices.filter((unit) => unit.status === '关注').length
const healthyCount = devices.filter((unit) => unit.status === '健康').length
const avgSoh = averageNullable(devices.map((unit) => unit.soh))
const avgSoh30d = averageNullable(devices.map((unit) => unit.soh30d))
const avgSoh90d = averageNullable(devices.map((unit) => unit.soh90d))
const warningCount = devices.filter((unit) => unit.status === DEVICE_STATUS.WARNING).length
const watchCount = devices.filter((unit) => unit.status === DEVICE_STATUS.WATCH).length
const healthyCount = devices.filter((unit) => unit.status === DEVICE_STATUS.HEALTHY).length
const batchPerformance = summarizeBy(devices, (unit) => unit.batch)
.map(([batch, data]) => ({ batch, avgSoh: round1(data.sum / data.count) }))
.sort((a, b) => b.avgSoh - a.avgSoh)
.map(([batch, data]) => ({ batch, avgSoh: data.valueCount > 0 ? round1(data.sum / data.valueCount) : null }))
.sort((a, b) => (b.avgSoh ?? -1) - (a.avgSoh ?? -1))
const firmwareHealth = summarizeBy(devices, (unit) => unit.firmware)
.map(([firmware, data]) => ({ firmware, avgSoh: round1(data.sum / data.count), count: data.count }))
.sort((a, b) => b.avgSoh - a.avgSoh)
.map(([firmware, data]) => ({
firmware,
avgSoh: data.valueCount > 0 ? round1(data.sum / data.valueCount) : null,
count: data.count,
}))
.sort((a, b) => (b.avgSoh ?? -1) - (a.avgSoh ?? -1))
const riskFactorCounts = Object.entries(
devices.reduce<Record<string, number>>((acc, unit) => {
for (const factor of unit.riskFactors) {
@@ -345,9 +396,9 @@ function createSummary(devices: FleetUnit[], now: Date) {
return {
totalDevices,
avgSoh: round1(avgSoh),
avgSoh30d: round1(avgSoh30d),
avgSoh90d: round1(avgSoh90d),
avgSoh: avgSoh === null ? null : round1(avgSoh),
avgSoh30d: avgSoh30d === null ? null : round1(avgSoh30d),
avgSoh90d: avgSoh90d === null ? null : round1(avgSoh90d),
warningCount,
watchCount,
healthyCount,
@@ -355,7 +406,10 @@ function createSummary(devices: FleetUnit[], now: Date) {
riskFactorCounts,
firmwareHealth,
updatedAt: formatDateTime(now),
executiveSummary: `当前电池健康度总体可控,重点风险集中在 ${weakestBatch} 批次与 ${weakestFirmware} 固件设备。建议优先跟踪低电量、充电状态与未来 90 天 SoH 变化。`,
executiveSummary:
avgSoh === null
? '当前 AI SoH 预测不可用,页面仅展示 MySQL 采集电量、充电状态与低电量风险。请检查预测服务配置或历史数据量。'
: `当前电池健康度总体可控,重点风险集中在 ${weakestBatch} 批次与 ${weakestFirmware} 固件设备。建议优先跟踪低电量、充电状态与未来 90 天 SoH 变化。`,
}
}
@@ -371,14 +425,20 @@ function createEvents(devices: FleetUnit[], now: Date) {
{
time: formatEventTime(addHours(now, -2)),
title: `${first.id} 进入重点观察队列`,
detail: `${first.id} 当前 SoH 为 ${first.soh.toFixed(1)}%,综合风险评分 ${first.riskScore}`,
severity: first.status === '预警' ? '高' : '中',
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: formatEventTime(addHours(now, -5)),
title: `${second.batch} 批次健康度趋势更新`,
detail: `${second.batch} 批次未来 90 天预测 SoH 为 ${second.soh90d.toFixed(1)}%。`,
severity: second.status === '健康' ? '低' : '中',
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,
},
] satisfies DashboardSnapshot['events']
}
@@ -386,7 +446,7 @@ function createEvents(devices: FleetUnit[], now: Date) {
function createStrategies(devices: FleetUnit[]) {
if (devices.length === 0) return []
const warningDevices = devices.filter((unit) => unit.status === '预警')
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]
+1 -1
View File
@@ -7,7 +7,7 @@ export const env = createEnv({
LOG_DB: z.stringbool().default(false),
LOG_FORMAT: z.enum(['pretty', 'json']).optional(),
LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warning', 'error', 'fatal']).default('info'),
SOH_PREDICTION_API_BASE_URL: z.url({ protocol: /^https?$/ }).optional(),
SOH_PREDICTION_API_BASE_URL: z.url({ protocol: /^https?$/ }),
SOH_PREDICTION_CACHE_TTL_SECONDS: z.coerce.number().int().positive().default(86_400),
SOH_PREDICTION_TIMEOUT_MS: z.coerce.number().int().positive().default(10_000),
},
+24 -27
View File
@@ -4,13 +4,8 @@ import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from '
import { useEffect, useMemo, useState } from 'react'
import { z } from 'zod'
import { orpc } from '@/client/orpc'
import type { BatteryInfo } from '@/domain/battery'
const sortOptions = ['createdAtDesc', 'createdAtAsc', 'powerDesc', 'powerAsc'] as const
type BatteryListSort = (typeof sortOptions)[number]
const powerStatusOptions = [0, 1, 2] as const
type PowerStatusFilter = (typeof powerStatusOptions)[number]
import type { BatteryInfo, BatteryListSort, PowerStatus } from '@/domain/battery'
import { BATTERY_LIST_SORT, BATTERY_LIST_SORT_VALUES, POWER_STATUS, POWER_STATUS_VALUES } from '@/domain/battery'
const pageSizeOptions = [20, 50, 100] as const
type PageSizeOption = (typeof pageSizeOptions)[number]
@@ -29,8 +24,10 @@ const cursorSchema = z.preprocess(
const searchSchema = z.object({
search: searchFilterSchema,
lowPower: z.boolean().optional(),
powerStatus: z.union([z.literal(0), z.literal(1), z.literal(2)]).optional(),
sort: z.enum(sortOptions).optional().default('createdAtDesc'),
powerStatus: z
.union([z.literal(POWER_STATUS.NOT_CHARGING), z.literal(POWER_STATUS.CHARGING), z.literal(POWER_STATUS.FULL)])
.optional(),
sort: z.enum(BATTERY_LIST_SORT_VALUES).optional().default(BATTERY_LIST_SORT.CREATED_AT_DESC),
pageSize: z.coerce
.number()
.pipe(z.union([z.literal(20), z.literal(50), z.literal(100)]))
@@ -53,16 +50,16 @@ export const Route = createFileRoute('/batteries')({
),
})
const powerStatusLabel: Record<0 | 1 | 2, string> = {
0: '未充电',
1: '充电中',
2: '已充满',
const powerStatusLabel: Record<PowerStatus, string> = {
[POWER_STATUS.NOT_CHARGING]: '未充电',
[POWER_STATUS.CHARGING]: '充电中',
[POWER_STATUS.FULL]: '已充满',
}
const powerStatusColor: Record<0 | 1 | 2, string> = {
0: 'text-zinc-400',
1: 'text-teal-400',
2: 'text-emerald-400',
const powerStatusColor: Record<PowerStatus, string> = {
[POWER_STATUS.NOT_CHARGING]: 'text-zinc-400',
[POWER_STATUS.CHARGING]: 'text-teal-400',
[POWER_STATUS.FULL]: 'text-emerald-400',
}
function powerBarColor(power: number, isLowPower: boolean): string {
@@ -74,13 +71,13 @@ function powerBarColor(power: number, isLowPower: boolean): string {
const columnHelper = createColumnHelper<BatteryInfo>()
function parseSort(value: string): BatteryListSort {
return sortOptions.find((option) => option === value) ?? 'createdAtDesc'
return BATTERY_LIST_SORT_VALUES.find((option) => option === value) ?? BATTERY_LIST_SORT.CREATED_AT_DESC
}
function parsePowerStatus(value: string): PowerStatusFilter | undefined {
function parsePowerStatus(value: string): PowerStatus | undefined {
const parsed = Number(value)
return powerStatusOptions.find((option) => option === parsed)
return POWER_STATUS_VALUES.find((option) => option === parsed)
}
function parsePageSize(value: string): PageSizeOption {
@@ -284,9 +281,9 @@ function BatteriesPage() {
}}
>
<option value=""></option>
<option value="0"></option>
<option value="1"></option>
<option value="2"></option>
<option value={POWER_STATUS.NOT_CHARGING}></option>
<option value={POWER_STATUS.CHARGING}></option>
<option value={POWER_STATUS.FULL}></option>
</select>
<label className="flex items-center gap-2 text-sm text-zinc-400 cursor-pointer">
@@ -324,10 +321,10 @@ function BatteriesPage() {
})
}}
>
<option value="createdAtDesc"></option>
<option value="createdAtAsc"></option>
<option value="powerDesc"></option>
<option value="powerAsc"></option>
<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>
<select
+139 -110
View File
@@ -14,6 +14,7 @@ import {
import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent'
import { orpc } from '@/client/orpc'
import type { DashboardSnapshot, DeviceStatus } from '@/domain/battery'
import { BATTERY_LIST_SORT, DEVICE_STATUS } from '@/domain/battery'
export const Route = createFileRoute('/')({
component: Dashboard,
@@ -57,9 +58,9 @@ function buildChartData(soh: DashboardSnapshot['soh']) {
}
const statusColorMap: Record<DeviceStatus, string> = {
: 'text-emerald-400',
: 'text-amber-400',
: 'text-red-400',
[DEVICE_STATUS.HEALTHY]: 'text-emerald-400',
[DEVICE_STATUS.WATCH]: 'text-amber-400',
[DEVICE_STATUS.WARNING]: 'text-red-400',
}
const severityColorMap: Record<DashboardSnapshot['events'][number]['severity'], string> = {
@@ -77,6 +78,23 @@ function formatChartTooltip(value: ValueType | undefined, name: NameType | undef
]
}
function formatPercent(value: number | null) {
return value === null ? '—' : value.toFixed(1)
}
function formatPercentWithUnit(value: number | null) {
return value === null ? '预测不可用' : `${value.toFixed(1)}%`
}
function formatDelta(from: number | null, to: number | null) {
if (from === null || to === null) return '预测不可用'
return `${(from - to).toFixed(1)}% 衰减`
}
function widthPercent(value: number | null) {
return `${Math.max(0, Math.min(100, value ?? 0))}%`
}
function Dashboard() {
const { data, error, isPending } = useQuery(orpc.battery.dashboard.queryOptions())
@@ -147,7 +165,7 @@ function Dashboard() {
<p className="text-xs tabular-nums text-[#71717A]">: {updatedAt}</p>
<Link
to="/batteries"
search={{ pageSize: 50, sort: 'createdAtDesc', cursors: [] }}
search={{ pageSize: 50, sort: BATTERY_LIST_SORT.CREATED_AT_DESC, cursors: [] }}
className="text-xs text-teal-400 hover:text-teal-300"
>
@@ -168,13 +186,13 @@ function Dashboard() {
<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>
<div className="mt-4 flex items-baseline gap-2">
<h2 className="text-6xl font-light tabular-nums text-white">{avgSoh.toFixed(1)}</h2>
<span className="text-2xl text-[#71717A]">%</span>
<h2 className="text-6xl font-light tabular-nums text-white">{formatPercent(avgSoh)}</h2>
{avgSoh !== null && <span className="text-2xl text-[#71717A]">%</span>}
</div>
<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预测不可用' : '基线健康'}
</span>
<span className="text-[#71717A]">|</span>
<span className="text-[#A1A1AA]"> {totalDevices} </span>
@@ -185,24 +203,24 @@ function Dashboard() {
<article className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-6 transition-colors hover:border-white/10">
<p className="text-sm text-[#A1A1AA]">30 </p>
<div className="mt-3 flex items-baseline gap-1">
<h2 className="text-4xl font-light tabular-nums text-white">{avgSoh30d.toFixed(1)}</h2>
<span className="text-lg text-[#71717A]">%</span>
<h2 className="text-4xl font-light tabular-nums text-white">{formatPercent(avgSoh30d)}</h2>
{avgSoh30d !== null && <span className="text-lg text-[#71717A]">%</span>}
</div>
<div className="mt-4 flex items-center gap-1.5 text-sm">
<span className="text-red-400"></span>
<span className="tabular-nums text-[#A1A1AA]">{(avgSoh - avgSoh30d).toFixed(1)}% </span>
<span className="tabular-nums text-[#A1A1AA]">{formatDelta(avgSoh, avgSoh30d)}</span>
</div>
</article>
<article className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-6 transition-colors hover:border-white/10">
<p className="text-sm text-[#A1A1AA]">90 </p>
<div className="mt-3 flex items-baseline gap-1">
<h2 className="text-4xl font-light tabular-nums text-white">{avgSoh90d.toFixed(1)}</h2>
<span className="text-lg text-[#71717A]">%</span>
<h2 className="text-4xl font-light tabular-nums text-white">{formatPercent(avgSoh90d)}</h2>
{avgSoh90d !== null && <span className="text-lg text-[#71717A]">%</span>}
</div>
<div className="mt-4 flex items-center gap-1.5 text-sm">
<span className="text-red-400"></span>
<span className="tabular-nums text-[#A1A1AA]">{(avgSoh - avgSoh90d).toFixed(1)}% </span>
<span className="tabular-nums text-[#A1A1AA]">{formatDelta(avgSoh, avgSoh90d)}</span>
</div>
</article>
@@ -245,97 +263,103 @@ function Dashboard() {
</header>
<div className="w-full h-[320px]">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ top: 24, right: 24, bottom: 8, left: 0 }}>
<defs>
<linearGradient id="historyFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#2DD4BF" stopOpacity={0.15} />
<stop offset="100%" stopColor="#2DD4BF" stopOpacity={0} />
</linearGradient>
<linearGradient id="forecastFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#818CF8" stopOpacity={0.15} />
<stop offset="100%" stopColor="#818CF8" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid stroke="#ffffff" strokeOpacity={0.05} vertical={false} />
<XAxis dataKey="month" tick={{ fill: '#71717A', fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis
domain={[(min: number) => Math.floor(min) - 2, (max: number) => Math.ceil(max) + 2]}
tick={{ fill: '#71717A', fontSize: 11 }}
axisLine={false}
tickLine={false}
tickFormatter={(v: number) => `${v}%`}
width={48}
/>
<Tooltip
contentStyle={{
backgroundColor: '#18181B',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 8,
fontSize: 13,
color: '#F4F4F5',
}}
itemStyle={{ color: '#A1A1AA' }}
formatter={formatChartTooltip}
labelStyle={{ color: '#71717A', marginBottom: 4 }}
/>
<ReferenceLine
y={85}
stroke="#F87171"
strokeOpacity={0.4}
strokeDasharray="4 4"
label={{
value: '85% 预警线',
fill: '#F87171',
fontSize: 11,
position: 'right',
}}
/>
<Area
type="monotone"
dataKey="history"
fill="url(#historyFill)"
stroke="none"
connectNulls={false}
tooltipType="none"
/>
<Area
type="monotone"
dataKey="forecast"
fill="url(#forecastFill)"
stroke="none"
connectNulls={false}
tooltipType="none"
/>
<Line
type="monotone"
dataKey="history"
stroke="#2DD4BF"
strokeWidth={2.5}
dot={{
fill: '#09090B',
stroke: '#2DD4BF',
strokeWidth: 2,
r: 3,
}}
connectNulls={false}
/>
<Line
type="monotone"
dataKey="forecast"
stroke="#818CF8"
strokeWidth={2.5}
strokeDasharray="4 4"
dot={{
fill: '#09090B',
stroke: '#818CF8',
strokeWidth: 2,
r: 3,
}}
connectNulls={false}
/>
</ComposedChart>
</ResponsiveContainer>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ top: 24, right: 24, bottom: 8, left: 0 }}>
<defs>
<linearGradient id="historyFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#2DD4BF" stopOpacity={0.15} />
<stop offset="100%" stopColor="#2DD4BF" stopOpacity={0} />
</linearGradient>
<linearGradient id="forecastFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#818CF8" stopOpacity={0.15} />
<stop offset="100%" stopColor="#818CF8" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid stroke="#ffffff" strokeOpacity={0.05} vertical={false} />
<XAxis dataKey="month" tick={{ fill: '#71717A', fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis
domain={[(min: number) => Math.floor(min) - 2, (max: number) => Math.ceil(max) + 2]}
tick={{ fill: '#71717A', fontSize: 11 }}
axisLine={false}
tickLine={false}
tickFormatter={(v: number) => `${v}%`}
width={48}
/>
<Tooltip
contentStyle={{
backgroundColor: '#18181B',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 8,
fontSize: 13,
color: '#F4F4F5',
}}
itemStyle={{ color: '#A1A1AA' }}
formatter={formatChartTooltip}
labelStyle={{ color: '#71717A', marginBottom: 4 }}
/>
<ReferenceLine
y={85}
stroke="#F87171"
strokeOpacity={0.4}
strokeDasharray="4 4"
label={{
value: '85% 预警线',
fill: '#F87171',
fontSize: 11,
position: 'right',
}}
/>
<Area
type="monotone"
dataKey="history"
fill="url(#historyFill)"
stroke="none"
connectNulls={false}
tooltipType="none"
/>
<Area
type="monotone"
dataKey="forecast"
fill="url(#forecastFill)"
stroke="none"
connectNulls={false}
tooltipType="none"
/>
<Line
type="monotone"
dataKey="history"
stroke="#2DD4BF"
strokeWidth={2.5}
dot={{
fill: '#09090B',
stroke: '#2DD4BF',
strokeWidth: 2,
r: 3,
}}
connectNulls={false}
/>
<Line
type="monotone"
dataKey="forecast"
stroke="#818CF8"
strokeWidth={2.5}
strokeDasharray="4 4"
dot={{
fill: '#09090B',
stroke: '#818CF8',
strokeWidth: 2,
r: 3,
}}
connectNulls={false}
/>
</ComposedChart>
</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>
</article>
</section>
@@ -412,10 +436,15 @@ function Dashboard() {
<span className="w-20 text-sm text-[#A1A1AA]">{item.batch}</span>
<div className="flex-1">
<div className="h-1.5 overflow-hidden rounded-full bg-white/5">
<div className="h-full rounded-full bg-white/20" style={{ width: `${item.avgSoh}%` }} />
<div
className="h-full rounded-full bg-white/20"
style={{ width: widthPercent(item.avgSoh) }}
/>
</div>
</div>
<span className="w-12 text-right text-sm tabular-nums text-white">{item.avgSoh.toFixed(1)}%</span>
<span className="w-20 text-right text-sm tabular-nums text-white">
{formatPercentWithUnit(item.avgSoh)}
</span>
</div>
))
) : (
@@ -511,9 +540,9 @@ function Dashboard() {
<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">{unit.soh.toFixed(1)}%</td>
<td className="px-6 py-4 tabular-nums text-[#A1A1AA]">{unit.soh30d.toFixed(1)}%</td>
<td className="px-6 py-4 tabular-nums text-[#A1A1AA]">{unit.soh90d.toFixed(1)}%</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">
@@ -604,7 +633,7 @@ function Dashboard() {
<div className="text-xs text-[#71717A]">{item.count} </div>
</div>
<div className="text-right">
<div className="text-sm tabular-nums text-white">{item.avgSoh.toFixed(1)}%</div>
<div className="text-sm tabular-nums text-white">{formatPercentWithUnit(item.avgSoh)}</div>
<div className="text-xs text-[#71717A]"> SoH</div>
</div>
</div>
+11 -3
View File
@@ -1,6 +1,12 @@
import { oc } from '@orpc/contract'
import { z } from 'zod'
import { batteriesResponseSchema, dashboardSnapshotSchema } from '@/domain/battery'
import {
BATTERY_LIST_SORT,
BATTERY_LIST_SORT_VALUES,
batteriesResponseSchema,
dashboardSnapshotSchema,
POWER_STATUS,
} from '@/domain/battery'
export const dashboard = oc.input(z.void()).output(dashboardSnapshotSchema)
@@ -12,8 +18,10 @@ const batteryListInputSchema = z.object({
z.string().min(1).max(100).optional(),
),
lowPower: z.boolean().optional(),
powerStatus: z.union([z.literal(0), z.literal(1), z.literal(2)]).optional(),
sort: z.enum(['createdAtDesc', 'createdAtAsc', 'powerDesc', 'powerAsc']).default('createdAtDesc'),
powerStatus: z
.union([z.literal(POWER_STATUS.NOT_CHARGING), z.literal(POWER_STATUS.CHARGING), z.literal(POWER_STATUS.FULL)])
.optional(),
sort: z.enum(BATTERY_LIST_SORT_VALUES).default(BATTERY_LIST_SORT.CREATED_AT_DESC),
})
export const batteries = oc.input(batteryListInputSchema).output(batteriesResponseSchema)
+32 -19
View File
@@ -1,6 +1,16 @@
import mysql, { type Pool, type RowDataPacket } from 'mysql2/promise'
import { type BatteryInfo, type BatteryInfoSourceRow, toBatteryInfo } from '@/domain/battery'
import {
BATTERY_LIST_SORT,
type BatteryInfo,
type BatteryInfoSourceRow,
type BatteryListSort,
MYSQL_BOOLEAN,
POWER_STATUS,
type PowerStatus,
toBatteryInfo,
toMysqlBoolean,
} from '@/domain/battery'
import { env } from '@/env'
const historyLimit = 500
@@ -14,14 +24,12 @@ type CountMysqlRow = RowDataPacket & {
charging: number | string | null
}
export type BatteryListSort = 'createdAtDesc' | 'createdAtAsc' | 'powerDesc' | 'powerAsc'
export type LatestBatteryPageInput = {
pageSize: number
cursor?: string
search?: string
lowPower?: boolean
powerStatus?: 0 | 1 | 2
powerStatus?: PowerStatus
sort?: BatteryListSort
}
@@ -99,10 +107,10 @@ const latestRecordPredicate = `
`
const orderByBySort: Record<BatteryListSort, string> = {
createdAtDesc: 'current_record.create_time DESC, current_record.id DESC',
createdAtAsc: 'current_record.create_time ASC, current_record.id ASC',
powerDesc: 'current_record.power DESC, current_record.create_time DESC, current_record.id DESC',
powerAsc: 'current_record.power ASC, current_record.create_time DESC, current_record.id DESC',
[BATTERY_LIST_SORT.CREATED_AT_DESC]: 'current_record.create_time DESC, current_record.id DESC',
[BATTERY_LIST_SORT.CREATED_AT_ASC]: 'current_record.create_time ASC, current_record.id ASC',
[BATTERY_LIST_SORT.POWER_DESC]: 'current_record.power DESC, current_record.create_time DESC, current_record.id DESC',
[BATTERY_LIST_SORT.POWER_ASC]: 'current_record.power ASC, current_record.create_time DESC, current_record.id DESC',
}
function toNumber(value: number | string | null | undefined) {
@@ -115,7 +123,7 @@ function encodeCursor(item: BatteryInfo, sort: BatteryListSort) {
sort,
createTime: item.createTime,
id: item.id,
power: sort === 'powerAsc' || sort === 'powerDesc' ? item.power : undefined,
power: sort === BATTERY_LIST_SORT.POWER_ASC || sort === BATTERY_LIST_SORT.POWER_DESC ? item.power : undefined,
}
return Buffer.from(JSON.stringify(cursor)).toString('base64url')
@@ -127,7 +135,12 @@ function decodeCursor(value: string | undefined, sort: BatteryListSort): PageCur
try {
const decoded = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as Partial<PageCursor>
if (decoded.sort !== sort || typeof decoded.createTime !== 'string' || typeof decoded.id !== 'number') return null
if ((sort === 'powerAsc' || sort === 'powerDesc') && typeof decoded.power !== 'number') return null
if (
(sort === BATTERY_LIST_SORT.POWER_ASC || sort === BATTERY_LIST_SORT.POWER_DESC) &&
typeof decoded.power !== 'number'
) {
return null
}
return decoded as PageCursor
} catch {
@@ -156,7 +169,7 @@ function createLatestWhere(input: LatestBatteryPageInput, cursor: PageCursor | n
if (input.lowPower !== undefined) {
clauses.push('current_record.is_low_power = :lowPower')
params.lowPower = input.lowPower ? 'true' : 'false'
params.lowPower = toMysqlBoolean(input.lowPower)
}
if (input.powerStatus !== undefined) {
@@ -168,25 +181,25 @@ function createLatestWhere(input: LatestBatteryPageInput, cursor: PageCursor | n
params.cursorCreateTime = normalizeCursorDateTime(cursor.createTime)
params.cursorId = cursor.id
switch (input.sort ?? 'createdAtDesc') {
case 'createdAtAsc':
switch (input.sort ?? BATTERY_LIST_SORT.CREATED_AT_DESC) {
case BATTERY_LIST_SORT.CREATED_AT_ASC:
clauses.push(
'(current_record.create_time > :cursorCreateTime OR (current_record.create_time = :cursorCreateTime AND current_record.id > :cursorId))',
)
break
case 'powerDesc':
case BATTERY_LIST_SORT.POWER_DESC:
params.cursorPower = cursor.power ?? 0
clauses.push(
'(current_record.power < :cursorPower OR (current_record.power = :cursorPower AND (current_record.create_time < :cursorCreateTime OR (current_record.create_time = :cursorCreateTime AND current_record.id < :cursorId))))',
)
break
case 'powerAsc':
case BATTERY_LIST_SORT.POWER_ASC:
params.cursorPower = cursor.power ?? 0
clauses.push(
'(current_record.power > :cursorPower OR (current_record.power = :cursorPower AND (current_record.create_time < :cursorCreateTime OR (current_record.create_time = :cursorCreateTime AND current_record.id < :cursorId))))',
)
break
case 'createdAtDesc':
case BATTERY_LIST_SORT.CREATED_AT_DESC:
clauses.push(
'(current_record.create_time < :cursorCreateTime OR (current_record.create_time = :cursorCreateTime AND current_record.id < :cursorId))',
)
@@ -257,7 +270,7 @@ export async function getBatteryPredictionHistories(macAddresses: string[]): Pro
}
export async function getLatestBatteryPage(input: LatestBatteryPageInput): Promise<LatestBatteryPage> {
const sort = input.sort ?? 'createdAtDesc'
const sort = input.sort ?? BATTERY_LIST_SORT.CREATED_AT_DESC
const pageSize = Math.min(Math.max(input.pageSize, 1), 100)
const cursor = decodeCursor(input.cursor, sort)
const { whereSql, params } = createLatestWhere({ ...input, sort, pageSize }, cursor)
@@ -283,8 +296,8 @@ export async function getLatestBatteryPage(input: LatestBatteryPageInput): Promi
`
SELECT
COUNT(*) AS total,
COALESCE(SUM(CASE WHEN current_record.is_low_power = 'true' THEN 1 ELSE 0 END), 0) AS lowPower,
COALESCE(SUM(CASE WHEN current_record.power_status = 1 THEN 1 ELSE 0 END), 0) AS charging
COALESCE(SUM(CASE WHEN current_record.is_low_power = '${MYSQL_BOOLEAN.TRUE}' THEN 1 ELSE 0 END), 0) AS lowPower,
COALESCE(SUM(CASE WHEN current_record.power_status = ${POWER_STATUS.CHARGING} THEN 1 ELSE 0 END), 0) AS charging
FROM ls_battery_info AS current_record
WHERE ${countWhere.whereSql}
`,
+11 -9
View File
@@ -1,6 +1,12 @@
import { LRUCache } from 'lru-cache'
import { z } from 'zod'
import type { BatteryInfo, BatteryPrediction } from '@/domain/battery'
import {
type BatteryInfo,
type BatteryPrediction,
POWER_STATUS,
type PowerStatus,
toMysqlBoolean,
} from '@/domain/battery'
import { env } from '@/env'
import { getLogger } from '@/server/logger'
@@ -39,7 +45,7 @@ type PredictionRequest = {
dev_model: string
dev_name: string
is_low_power: string
power_status: 0 | 1 | 2
power_status: PowerStatus
power: number
create_time: string
remark: string
@@ -98,7 +104,7 @@ function createHistoryItem(item: BatteryInfo, index: number): PredictionHistoryI
discharge_capacity_ah: dischargeCapacity,
charge_energy_wh: chargeEnergy,
discharge_energy_wh: dischargeEnergy,
charge_time: item.powerStatus === 1 ? '01:20:00' : '01:18:00',
charge_time: item.powerStatus === POWER_STATUS.CHARGING ? '01:20:00' : '01:18:00',
discharge_time: item.isLowPower ? '00:58:00' : '01:10:00',
coulombic_efficiency_pct: efficiency,
timestamp: normalizeMysqlDateTime(item.createTime),
@@ -117,7 +123,7 @@ export function createPredictionRequest(battery: BatteryInfo, history: BatteryIn
mac: battery.mac,
dev_model: battery.devModel,
dev_name: battery.devName,
is_low_power: battery.isLowPower ? 'true' : 'false',
is_low_power: toMysqlBoolean(battery.isLowPower),
power_status: battery.powerStatus,
power: battery.power,
create_time: normalizeMysqlDateTime(battery.createTime),
@@ -144,12 +150,10 @@ function normalizePrediction(response: z.infer<typeof predictionResponseSchema>)
}
export function isPredictionEnabled() {
return Boolean(env.SOH_PREDICTION_API_BASE_URL)
return true
}
export async function predictSoh(battery: BatteryInfo, history: BatteryInfo[]): Promise<SohPrediction | null> {
if (!env.SOH_PREDICTION_API_BASE_URL) return null
const request = createPredictionRequest(battery, history)
if (!request) return null
@@ -170,8 +174,6 @@ async function requestPrediction(
battery: BatteryInfo,
request: PredictionRequest,
): Promise<SohPrediction | null> {
if (!env.SOH_PREDICTION_API_BASE_URL) return null
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), env.SOH_PREDICTION_TIMEOUT_MS)
const baseUrl = env.SOH_PREDICTION_API_BASE_URL.endsWith('/')