489 lines
19 KiB
TypeScript
489 lines
19 KiB
TypeScript
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, FilterX, Search, Zap } from 'lucide-react'
|
||
import { useEffect, useMemo, useState } from 'react'
|
||
import { z } from 'zod'
|
||
import { orpc } from '@/client/orpc'
|
||
import { MotionCardDiv, MotionHeader, MotionSection, MotionTableRow } from '@/components/motion'
|
||
import { Badge, Button, Card, Input, SectionTitle, Select, SelectOption } from '@/components/ui'
|
||
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]
|
||
const firstPageCursor = '__FIRST_PAGE__'
|
||
const allPowerStatusValue = 'all'
|
||
|
||
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(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)]))
|
||
.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: () => (
|
||
<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">请检查筛选条件后重试。</p>
|
||
</div>
|
||
</main>
|
||
),
|
||
})
|
||
|
||
const powerStatusLabel: Record<PowerStatus, string> = {
|
||
[POWER_STATUS.NOT_CHARGING]: '未充电',
|
||
[POWER_STATUS.CHARGING]: '充电中',
|
||
[POWER_STATUS.FULL]: '已充满',
|
||
}
|
||
|
||
const powerStatusVariant: Record<PowerStatus, 'muted' | 'info' | 'success'> = {
|
||
[POWER_STATUS.NOT_CHARGING]: 'muted',
|
||
[POWER_STATUS.CHARGING]: 'info',
|
||
[POWER_STATUS.FULL]: 'success',
|
||
}
|
||
|
||
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'
|
||
}
|
||
|
||
const columnHelper = createColumnHelper<BatteryInfo>()
|
||
|
||
function parseSort(value: string): BatteryListSort {
|
||
return BATTERY_LIST_SORT_VALUES.find((option) => option === value) ?? BATTERY_LIST_SORT.CREATED_AT_DESC
|
||
}
|
||
|
||
function parsePowerStatus(value: string): PowerStatus | undefined {
|
||
if (value === allPowerStatusValue) return undefined
|
||
|
||
const parsed = Number(value)
|
||
|
||
return POWER_STATUS_VALUES.find((option) => option === parsed)
|
||
}
|
||
|
||
function parsePageSize(value: string): PageSizeOption {
|
||
const parsed = Number(value)
|
||
|
||
return pageSizeOptions.find((option) => option === parsed) ?? 50
|
||
}
|
||
|
||
function BatteriesPage() {
|
||
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: {
|
||
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) => (
|
||
<div className="flex flex-col">
|
||
<span className="font-medium text-zinc-100">{info.getValue()}</span>
|
||
<span className="text-[10px] text-zinc-500 tabular-nums">{info.row.original.mac}</span>
|
||
</div>
|
||
),
|
||
}),
|
||
columnHelper.accessor('devModel', {
|
||
header: '型号',
|
||
cell: (info) => <span className="text-zinc-400">{info.getValue()}</span>,
|
||
}),
|
||
columnHelper.accessor('power', {
|
||
header: '电量',
|
||
cell: (info) => {
|
||
const power = info.getValue()
|
||
const isLow = info.row.original.isLowPower
|
||
return (
|
||
<div className="flex items-center gap-3">
|
||
<span className="w-8 text-right tabular-nums font-medium">{power}%</span>
|
||
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-zinc-800">
|
||
<div className={`h-full ${powerBarColor(power, isLow)}`} style={{ width: `${power}%` }} />
|
||
</div>
|
||
</div>
|
||
)
|
||
},
|
||
}),
|
||
columnHelper.accessor('powerStatus', {
|
||
header: '状态',
|
||
cell: (info) => {
|
||
const status = info.getValue()
|
||
return <Badge variant={powerStatusVariant[status]}>{powerStatusLabel[status]}</Badge>
|
||
},
|
||
}),
|
||
columnHelper.accessor('createTime', {
|
||
header: '最后更新',
|
||
cell: (info) => (
|
||
<span className="text-zinc-500 tabular-nums">{new Date(info.getValue()).toLocaleString('zh-CN')}</span>
|
||
),
|
||
}),
|
||
],
|
||
[],
|
||
)
|
||
|
||
const table = useReactTable({
|
||
data: data?.items ?? [],
|
||
columns,
|
||
getCoreRowModel: getCoreRowModel(),
|
||
})
|
||
|
||
const hasActiveFilters = Boolean(search.search || search.lowPower || search.powerStatus !== undefined)
|
||
const clearFilters = () => {
|
||
setLocalSearch('')
|
||
navigate({
|
||
search: (prev) => ({
|
||
...prev,
|
||
search: undefined,
|
||
lowPower: undefined,
|
||
powerStatus: undefined,
|
||
cursor: undefined,
|
||
cursors: [],
|
||
}),
|
||
})
|
||
}
|
||
|
||
const handleNextPage = () => {
|
||
if (!isPlaceholderData && data?.nextCursor) {
|
||
const nextCursor = data.nextCursor
|
||
navigate({
|
||
search: (prev) => ({
|
||
...prev,
|
||
cursor: nextCursor,
|
||
cursors: [...(prev.cursors || []), prev.cursor || firstPageCursor].slice(-100),
|
||
}),
|
||
})
|
||
}
|
||
}
|
||
|
||
const handlePrevPage = () => {
|
||
const newCursors = [...(search.cursors || [])]
|
||
const lastCursor = newCursors.pop()
|
||
navigate({
|
||
search: (prev) => ({
|
||
...prev,
|
||
cursor: lastCursor === firstPageCursor ? undefined : lastCursor,
|
||
cursors: newCursors,
|
||
}),
|
||
})
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<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">请稍后重试,或联系管理员检查数据源连接。</p>
|
||
</div>
|
||
</main>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<main className="min-h-screen bg-[#09090B] text-zinc-100">
|
||
{/* Background gradient */}
|
||
<div className="pointer-events-none fixed inset-0 z-0 flex justify-center">
|
||
<div className="h-[600px] w-[1000px] -translate-y-1/2 rounded-full bg-teal-900/5 blur-[100px]" />
|
||
</div>
|
||
|
||
<div className="relative z-10 mx-auto max-w-7xl px-6 py-8">
|
||
<MotionHeader>
|
||
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||
<div>
|
||
<Badge variant="info" className="mb-4">
|
||
<Battery className="size-3.5" /> 实时设备数据
|
||
</Badge>
|
||
<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">
|
||
查看最新设备状态,快速定位低电量、充电中和长期未更新的设备。
|
||
</p>
|
||
<p className="mt-2 text-xs tabular-nums text-zinc-500">
|
||
{data ? `更新于 ${new Date(data.updatedAt).toLocaleString('zh-CN')}` : '加载中…'}
|
||
</p>
|
||
</div>
|
||
<nav>
|
||
<Link
|
||
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" /> 返回健康看板
|
||
</Link>
|
||
</nav>
|
||
</div>
|
||
|
||
<dl className="mt-8 grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||
<MotionCardDiv className="rounded-2xl border border-white/[0.08] bg-zinc-950/60 shadow-2xl shadow-black/20 p-5">
|
||
<dt className="flex items-center gap-2 text-xs font-medium text-zinc-500">
|
||
<Battery className="size-4 text-zinc-400" /> 设备总数
|
||
</dt>
|
||
<dd className="mt-3 text-3xl font-light tabular-nums text-white">{data?.total ?? '-'}</dd>
|
||
</MotionCardDiv>
|
||
<MotionCardDiv className="rounded-2xl border border-white/[0.08] bg-zinc-950/60 shadow-2xl shadow-black/20 p-5">
|
||
<dt className="flex items-center gap-2 text-xs font-medium text-zinc-500">
|
||
<BatteryLow className="size-4 text-red-400" /> 低电量
|
||
</dt>
|
||
<dd className="mt-3 text-3xl font-light tabular-nums text-red-300">{data?.lowPower ?? '-'}</dd>
|
||
</MotionCardDiv>
|
||
<MotionCardDiv className="rounded-2xl border border-white/[0.08] bg-zinc-950/60 shadow-2xl shadow-black/20 p-5">
|
||
<dt className="flex items-center gap-2 text-xs font-medium text-zinc-500">
|
||
<BatteryCharging className="size-4 text-teal-300" /> 充电中
|
||
</dt>
|
||
<dd className="mt-3 text-3xl font-light tabular-nums text-teal-300">{data?.charging ?? '-'}</dd>
|
||
</MotionCardDiv>
|
||
</dl>
|
||
</MotionHeader>
|
||
|
||
<MotionSection delay={0.1} className="mt-10">
|
||
<Card className="mb-6 p-5">
|
||
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||
<SectionTitle
|
||
icon={<Search className="size-4" />}
|
||
title="筛选设备"
|
||
description="按设备名称、编号、电量与充电状态快速缩小排查范围。"
|
||
/>
|
||
{hasActiveFilters && (
|
||
<Button type="button" className="h-9 px-3 text-xs" onClick={clearFilters}>
|
||
<FilterX className="size-3.5" /> 清除筛选
|
||
</Button>
|
||
)}
|
||
</div>
|
||
<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">
|
||
设备名称 / 设备编号
|
||
</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="搜索设备名称或编号..."
|
||
maxLength={100}
|
||
className="pl-9"
|
||
value={localSearch}
|
||
onChange={(e) => setLocalSearch(e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-2">
|
||
<label htmlFor="power-status-select" className="text-xs font-medium text-zinc-500">
|
||
充电状态
|
||
</label>
|
||
<Select
|
||
id="power-status-select"
|
||
value={search.powerStatus ?? allPowerStatusValue}
|
||
onValueChange={(value) => {
|
||
navigate({
|
||
search: (prev) => ({
|
||
...prev,
|
||
powerStatus: parsePowerStatus(value),
|
||
cursor: undefined,
|
||
cursors: [],
|
||
}),
|
||
})
|
||
}}
|
||
>
|
||
<SelectOption value={allPowerStatusValue}>所有充电状态</SelectOption>
|
||
<SelectOption value={POWER_STATUS.NOT_CHARGING}>未充电</SelectOption>
|
||
<SelectOption value={POWER_STATUS.CHARGING}>充电中</SelectOption>
|
||
<SelectOption value={POWER_STATUS.FULL}>已充满</SelectOption>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-2">
|
||
<label htmlFor="sort-select" className="text-xs font-medium text-zinc-500">
|
||
排序
|
||
</label>
|
||
<Select
|
||
id="sort-select"
|
||
value={search.sort}
|
||
onValueChange={(value) => {
|
||
navigate({
|
||
search: (prev) => ({
|
||
...prev,
|
||
sort: parseSort(value),
|
||
cursor: undefined,
|
||
cursors: [],
|
||
}),
|
||
})
|
||
}}
|
||
>
|
||
<SelectOption value={BATTERY_LIST_SORT.CREATED_AT_DESC}>最新更新</SelectOption>
|
||
<SelectOption value={BATTERY_LIST_SORT.CREATED_AT_ASC}>最早更新</SelectOption>
|
||
<SelectOption value={BATTERY_LIST_SORT.POWER_DESC}>电量从高到低</SelectOption>
|
||
<SelectOption value={BATTERY_LIST_SORT.POWER_ASC}>电量从低到高</SelectOption>
|
||
</Select>
|
||
</div>
|
||
|
||
<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"
|
||
value={search.pageSize}
|
||
onValueChange={(value) => {
|
||
navigate({
|
||
search: (prev) => ({
|
||
...prev,
|
||
pageSize: parsePageSize(value),
|
||
cursor: undefined,
|
||
cursors: [],
|
||
}),
|
||
})
|
||
}}
|
||
>
|
||
<SelectOption value="20">20 条/页</SelectOption>
|
||
<SelectOption value="50">50 条/页</SelectOption>
|
||
<SelectOption value="100">100 条/页</SelectOption>
|
||
</Select>
|
||
</div>
|
||
|
||
<label
|
||
htmlFor="low-power-checkbox"
|
||
className="inline-flex h-10 cursor-pointer items-center gap-2 rounded-lg border border-white/10 bg-white/[0.04] px-3 text-sm text-zinc-300 transition-colors hover:bg-white/[0.07]"
|
||
>
|
||
<input
|
||
id="low-power-checkbox"
|
||
type="checkbox"
|
||
className="rounded border-white/10 bg-zinc-950 text-teal-500 focus:ring-teal-500/50"
|
||
checked={search.lowPower ?? false}
|
||
onChange={(e) =>
|
||
navigate({
|
||
search: (prev) => ({
|
||
...prev,
|
||
lowPower: e.target.checked || undefined,
|
||
cursor: undefined,
|
||
cursors: [],
|
||
}),
|
||
})
|
||
}
|
||
/>
|
||
<Zap className="size-4 text-amber-300" /> 仅显示低电量设备
|
||
</label>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card className="overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className={`w-full border-collapse text-left text-sm ${isPlaceholderData ? 'opacity-60' : ''}`}>
|
||
<thead>
|
||
{table.getHeaderGroups().map((headerGroup) => (
|
||
<tr key={headerGroup.id} className="border-b border-white/5 bg-white/[0.02]">
|
||
{headerGroup.headers.map((header) => (
|
||
<th key={header.id} className="px-6 py-4 font-medium text-zinc-500 whitespace-nowrap">
|
||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</thead>
|
||
<tbody className="divide-y divide-white/[0.02]">
|
||
{isPending && !isPlaceholderData ? (
|
||
<tr>
|
||
<td colSpan={columns.length} className="h-32 text-center text-zinc-500">
|
||
加载中…
|
||
</td>
|
||
</tr>
|
||
) : data?.items.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={columns.length} className="h-32 text-center text-zinc-500">
|
||
未找到符合条件的设备
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
table.getRowModel().rows.map((row) => (
|
||
<MotionTableRow key={row.id} className="transition-colors hover:bg-white/[0.02]">
|
||
{row.getVisibleCells().map((cell) => (
|
||
<td key={cell.id} className="px-6 py-4 whitespace-nowrap">
|
||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||
</td>
|
||
))}
|
||
</MotionTableRow>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</Card>
|
||
|
||
<div className="mt-6 flex items-center justify-between text-sm text-zinc-500">
|
||
<div>
|
||
当前显示 {data?.items.length ?? 0} 台设备
|
||
{data?.total ? ` (共 ${data.total} 台)` : ''}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
type="button"
|
||
onClick={handlePrevPage}
|
||
disabled={isPlaceholderData || (!search.cursor && (!search.cursors || search.cursors.length === 0))}
|
||
>
|
||
上一页
|
||
</Button>
|
||
<Button type="button" onClick={handleNextPage} disabled={isPlaceholderData || !data?.nextCursor}>
|
||
下一页
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</MotionSection>
|
||
</div>
|
||
</main>
|
||
)
|
||
}
|