diff --git a/src/routes/batteries.tsx b/src/routes/batteries.tsx index d882d1d..d4f791b 100644 --- a/src/routes/batteries.tsx +++ b/src/routes/batteries.tsx @@ -1,15 +1,53 @@ import { useQuery } from '@tanstack/react-query' -import { createFileRoute, Link } from '@tanstack/react-router' +import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' +import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table' +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] + +const pageSizeOptions = [20, 50, 100] as const +type PageSizeOption = (typeof pageSizeOptions)[number] +const firstPageCursor = '__FIRST_PAGE__' + +const searchFilterSchema = z.preprocess( + (value) => (typeof value === 'string' ? value.trim() || undefined : value), + z.string().min(1).max(100).optional(), +) + +const cursorSchema = z.preprocess( + (value) => (typeof value === 'string' ? value.trim() || undefined : value), + z.string().min(1).max(1024).optional(), +) + +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'), + pageSize: z.coerce + .number() + .pipe(z.union([z.literal(20), z.literal(50), z.literal(100)])) + .optional() + .default(50), + cursor: cursorSchema, + cursors: z.array(z.string().min(1).max(1024)).max(100).optional().default([]), +}) + export const Route = createFileRoute('/batteries')({ + validateSearch: (search) => searchSchema.parse(search), component: BatteriesPage, - errorComponent: ({ error }) => ( + errorComponent: () => (

数据加载失败

-

{error.message}

+

请检查筛选条件后重试。

), @@ -33,103 +71,354 @@ function powerBarColor(power: number, isLowPower: boolean): string { return 'bg-teal-500' } -function DeviceCard({ item }: { item: BatteryInfo }) { - return ( -
-
-
-

{item.devName}

-

{item.mac}

-
- {item.devModel} -
+const columnHelper = createColumnHelper() -
-
- - {item.power} - % - - {powerStatusLabel[item.powerStatus]} -
-
-
-
-
+function parseSort(value: string): BatteryListSort { + return sortOptions.find((option) => option === value) ?? 'createdAtDesc' +} - -
- ) +function parsePowerStatus(value: string): PowerStatusFilter | undefined { + const parsed = Number(value) + + return powerStatusOptions.find((option) => option === parsed) +} + +function parsePageSize(value: string): PageSizeOption { + const parsed = Number(value) + + return pageSizeOptions.find((option) => option === parsed) ?? 50 } function BatteriesPage() { - const { data, error, isPending } = useQuery( + const search = Route.useSearch() + const navigate = useNavigate({ from: Route.fullPath }) + const [localSearch, setLocalSearch] = useState(search.search || '') + + useEffect(() => { + setLocalSearch(search.search ?? '') + }, [search.search]) + + useEffect(() => { + const timer = setTimeout(() => { + const trimmedSearch = localSearch.trim() + const nextSearch = trimmedSearch || undefined + + if (nextSearch !== search.search) { + navigate({ + search: (prev) => ({ ...prev, search: nextSearch, cursor: undefined, cursors: [] }), + }) + } + }, 500) + return () => clearTimeout(timer) + }, [localSearch, navigate, search.search]) + + const { data, error, isPending, isPlaceholderData } = useQuery( orpc.battery.batteries.queryOptions({ - input: {}, + input: { + search: search.search, + lowPower: search.lowPower, + powerStatus: search.powerStatus, + sort: search.sort, + pageSize: search.pageSize, + cursor: search.cursor, + }, refetchInterval: 30_000, + placeholderData: (prev) => prev, }), ) + const columns = useMemo( + () => [ + columnHelper.accessor('devName', { + header: '设备名称', + cell: (info) => ( +
+ {info.getValue()} + {info.row.original.mac} +
+ ), + }), + columnHelper.accessor('devModel', { + header: '型号', + cell: (info) => {info.getValue()}, + }), + columnHelper.accessor('power', { + header: '电量', + cell: (info) => { + const power = info.getValue() + const isLow = info.row.original.isLowPower + return ( +
+ {power}% +
+
+
+
+ ) + }, + }), + columnHelper.accessor('powerStatus', { + header: '状态', + cell: (info) => { + const status = info.getValue() + return {powerStatusLabel[status]} + }, + }), + columnHelper.accessor('createTime', { + header: '最后更新', + cell: (info) => ( + {new Date(info.getValue()).toLocaleString('zh-CN')} + ), + }), + ], + [], + ) + + const table = useReactTable({ + data: data?.items ?? [], + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const handleNextPage = () => { + if (!isPlaceholderData && data?.nextCursor) { + const nextCursor = data.nextCursor + navigate({ + search: (prev) => ({ + ...prev, + cursor: nextCursor, + cursors: [...(prev.cursors || []), prev.cursor || firstPageCursor], + }), + }) + } + } + + const handlePrevPage = () => { + const newCursors = [...(search.cursors || [])] + const lastCursor = newCursors.pop() + navigate({ + search: (prev) => ({ + ...prev, + cursor: lastCursor === firstPageCursor ? undefined : lastCursor, + cursors: newCursors, + }), + }) + } + if (error) { return (

数据加载失败

-

{error.message}

+

请稍后重试,或联系管理员检查数据源连接。

) } - if (isPending || !data) { - return ( -
-

加载中…

-
- ) - } - return ( -
-
-
-
-

设备电池实时状态

-

更新 {new Date(data.updatedAt).toLocaleString('zh-CN')}

-
- -
+
+ {/* Background gradient */} +
+
+
-
-
-
设备总数
-
{data.total}
+
+
+
+
+

设备电池实时状态

+

+ {data ? `更新于 ${new Date(data.updatedAt).toLocaleString('zh-CN')}` : '加载中…'} +

+
+
-
-
低电量
-
{data.lowPower}
-
-
-
充电中
-
{data.charging}
-
-
-
-
- {data.items.length > 0 ? ( - data.items.map((item) => ) - ) : ( -
暂无设备数据
- )} -
+
+
+
设备总数
+
{data?.total ?? '-'}
+
+
+
低电量
+
{data?.lowPower ?? '-'}
+
+
+
充电中
+
{data?.charging ?? '-'}
+
+
+ + +
+ {/* Controls */} +
+
+ setLocalSearch(e.target.value)} + /> +
+ + + + + +
+ + + + +
+ +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {isPending && !isPlaceholderData ? ( + + + + ) : data?.items.length === 0 ? ( + + + + ) : ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + )) + )} + +
+ {flexRender(header.column.columnDef.header, header.getContext())} +
+ 加载中… +
+ 暂无匹配设备 +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+
+ +
+
+ 显示 {data?.items.length ?? 0} 台设备 + {data?.total ? ` (共 ${data.total} 台)` : ''} +
+
+ + +
+
+
+
) } diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 0957ccf..eb9fb29 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -145,7 +145,11 @@ function Dashboard() {

数据更新时间: {updatedAt}

- + 设备电池实时状态 →