refactor(api): 复用电池业务常量
This commit is contained in:
+35
-13
@@ -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,
|
||||
|
||||
+24
-27
@@ -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
|
||||
|
||||
@@ -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
@@ -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}
|
||||
`,
|
||||
|
||||
@@ -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('/')
|
||||
|
||||
Reference in New Issue
Block a user