feat(ui): 新增电池实时状态页
This commit is contained in:
+21
-3
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as HealthRouteImport } from './routes/health'
|
import { Route as HealthRouteImport } from './routes/health'
|
||||||
|
import { Route as BatteriesRouteImport } from './routes/batteries'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as ApiSplatRouteImport } from './routes/api/$'
|
import { Route as ApiSplatRouteImport } from './routes/api/$'
|
||||||
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc.$'
|
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc.$'
|
||||||
@@ -19,6 +20,11 @@ const HealthRoute = HealthRouteImport.update({
|
|||||||
path: '/health',
|
path: '/health',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const BatteriesRoute = BatteriesRouteImport.update({
|
||||||
|
id: '/batteries',
|
||||||
|
path: '/batteries',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -37,12 +43,14 @@ const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({
|
|||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/batteries': typeof BatteriesRoute
|
||||||
'/health': typeof HealthRoute
|
'/health': typeof HealthRoute
|
||||||
'/api/$': typeof ApiSplatRoute
|
'/api/$': typeof ApiSplatRoute
|
||||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/batteries': typeof BatteriesRoute
|
||||||
'/health': typeof HealthRoute
|
'/health': typeof HealthRoute
|
||||||
'/api/$': typeof ApiSplatRoute
|
'/api/$': typeof ApiSplatRoute
|
||||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||||
@@ -50,20 +58,22 @@ export interface FileRoutesByTo {
|
|||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/batteries': typeof BatteriesRoute
|
||||||
'/health': typeof HealthRoute
|
'/health': typeof HealthRoute
|
||||||
'/api/$': typeof ApiSplatRoute
|
'/api/$': typeof ApiSplatRoute
|
||||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/health' | '/api/$' | '/api/rpc/$'
|
fullPaths: '/' | '/batteries' | '/health' | '/api/$' | '/api/rpc/$'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/health' | '/api/$' | '/api/rpc/$'
|
to: '/' | '/batteries' | '/health' | '/api/$' | '/api/rpc/$'
|
||||||
id: '__root__' | '/' | '/health' | '/api/$' | '/api/rpc/$'
|
id: '__root__' | '/' | '/batteries' | '/health' | '/api/$' | '/api/rpc/$'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
|
BatteriesRoute: typeof BatteriesRoute
|
||||||
HealthRoute: typeof HealthRoute
|
HealthRoute: typeof HealthRoute
|
||||||
ApiSplatRoute: typeof ApiSplatRoute
|
ApiSplatRoute: typeof ApiSplatRoute
|
||||||
ApiRpcSplatRoute: typeof ApiRpcSplatRoute
|
ApiRpcSplatRoute: typeof ApiRpcSplatRoute
|
||||||
@@ -78,6 +88,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof HealthRouteImport
|
preLoaderRoute: typeof HealthRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/batteries': {
|
||||||
|
id: '/batteries'
|
||||||
|
path: '/batteries'
|
||||||
|
fullPath: '/batteries'
|
||||||
|
preLoaderRoute: typeof BatteriesRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/': {
|
'/': {
|
||||||
id: '/'
|
id: '/'
|
||||||
path: '/'
|
path: '/'
|
||||||
@@ -104,6 +121,7 @@ declare module '@tanstack/react-router' {
|
|||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
|
BatteriesRoute: BatteriesRoute,
|
||||||
HealthRoute: HealthRoute,
|
HealthRoute: HealthRoute,
|
||||||
ApiSplatRoute: ApiSplatRoute,
|
ApiSplatRoute: ApiSplatRoute,
|
||||||
ApiRpcSplatRoute: ApiRpcSplatRoute,
|
ApiRpcSplatRoute: ApiRpcSplatRoute,
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||||
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
|
import { orpc } from '@/client/orpc'
|
||||||
|
import type { BatteryInfo } from '@/domain/battery'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/batteries')({
|
||||||
|
component: BatteriesPage,
|
||||||
|
loader: async ({ context }) => {
|
||||||
|
await context.queryClient.ensureQueryData(orpc.battery.batteries.queryOptions({ input: {} }))
|
||||||
|
},
|
||||||
|
errorComponent: ({ error }) => (
|
||||||
|
<main className="flex min-h-screen items-center justify-center bg-[#09090B]">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-lg text-red-400">数据加载失败</p>
|
||||||
|
<p className="mt-2 text-sm text-zinc-500">{error.message}</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
const powerStatusLabel: Record<0 | 1 | 2, string> = {
|
||||||
|
0: '未充电',
|
||||||
|
1: '充电中',
|
||||||
|
2: '已充满',
|
||||||
|
}
|
||||||
|
|
||||||
|
const powerStatusColor: Record<0 | 1 | 2, string> = {
|
||||||
|
0: 'text-zinc-400',
|
||||||
|
1: 'text-teal-400',
|
||||||
|
2: 'text-emerald-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
function powerBarColor(power: number, isLowPower: boolean): string {
|
||||||
|
if (isLowPower || power <= 20) return 'bg-red-500'
|
||||||
|
if (power <= 50) return 'bg-amber-500'
|
||||||
|
return 'bg-teal-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceCard({ item }: { item: BatteryInfo }) {
|
||||||
|
return (
|
||||||
|
<article className="rounded-lg border border-zinc-800 bg-zinc-950 p-4">
|
||||||
|
<header className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-zinc-100">{item.devName}</h3>
|
||||||
|
<p className="mt-0.5 text-xs text-zinc-500">{item.mac}</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded bg-zinc-900 px-2 py-0.5 text-xs text-zinc-400">{item.devModel}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<span className="font-semibold text-2xl text-zinc-100">
|
||||||
|
{item.power}
|
||||||
|
<span className="ml-0.5 font-normal text-sm text-zinc-500">%</span>
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs ${powerStatusColor[item.powerStatus]}`}>{powerStatusLabel[item.powerStatus]}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-zinc-800">
|
||||||
|
<div className={`h-full ${powerBarColor(item.power, item.isLowPower)}`} style={{ width: `${item.power}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="mt-4 flex items-center justify-between text-xs text-zinc-500">
|
||||||
|
<span>更新 {new Date(item.createTime).toLocaleString('zh-CN')}</span>
|
||||||
|
{item.isLowPower && <span className="rounded bg-red-950 px-1.5 py-0.5 text-red-400">低电量</span>}
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BatteriesPage() {
|
||||||
|
const { data } = useSuspenseQuery(
|
||||||
|
orpc.battery.batteries.queryOptions({
|
||||||
|
input: {},
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-[#09090B] px-6 py-8 text-zinc-100">
|
||||||
|
<header className="mx-auto max-w-7xl">
|
||||||
|
<div className="flex items-end justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-semibold text-2xl">设备电池实时状态</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-500">更新 {new Date(data.updatedAt).toLocaleString('zh-CN')}</p>
|
||||||
|
</div>
|
||||||
|
<nav className="text-xs">
|
||||||
|
<Link to="/" className="text-zinc-500 hover:text-zinc-300">
|
||||||
|
← 返回 SoH 看板
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="mt-6 grid grid-cols-3 gap-4">
|
||||||
|
<div className="rounded-lg border border-zinc-800 bg-zinc-950 p-4">
|
||||||
|
<dt className="text-xs text-zinc-500">设备总数</dt>
|
||||||
|
<dd className="mt-1 font-semibold text-2xl">{data.total}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-zinc-800 bg-zinc-950 p-4">
|
||||||
|
<dt className="text-xs text-zinc-500">低电量</dt>
|
||||||
|
<dd className="mt-1 font-semibold text-2xl text-red-400">{data.lowPower}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-zinc-800 bg-zinc-950 p-4">
|
||||||
|
<dt className="text-xs text-zinc-500">充电中</dt>
|
||||||
|
<dd className="mt-1 font-semibold text-2xl text-teal-400">{data.charging}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="mx-auto mt-8 grid max-w-7xl grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{data.items.length > 0 ? (
|
||||||
|
data.items.map((item) => <DeviceCard key={item.id} item={item} />)
|
||||||
|
) : (
|
||||||
|
<div className="col-span-full py-12 text-center text-zinc-500">暂无设备数据</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user