Files
fullstack-starter/src/routes/batteries.tsx
T

489 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}