feat(ui): 优化看板和设备列表体验

This commit is contained in:
2026-05-12 01:01:15 +08:00
parent 6014af2690
commit e6b351e39c
2 changed files with 76 additions and 25 deletions
+54 -12
View File
@@ -1,12 +1,12 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table' import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'
import { ArrowLeft, Battery, BatteryCharging, BatteryLow, FilterX, Search, Zap } from 'lucide-react' import { ArrowLeft, Battery, BatteryCharging, BatteryLow, FilterX, Search, X, Zap } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { z } from 'zod' import { z } from 'zod'
import { orpc } from '@/client/orpc' import { orpc } from '@/client/orpc'
import { MotionCardDiv, MotionHeader, MotionSection, MotionTableRow } from '@/components/motion' import { MotionCardDiv, MotionHeader, MotionSection, MotionTableRow } from '@/components/motion'
import { Badge, Button, Card, Input, SectionTitle, Select, SelectOption } from '@/components/ui' import { Badge, Button, Card, EmptyState, Input, SectionTitle, Select, SelectOption, Skeleton } from '@/components/ui'
import type { BatteryInfo, BatteryListSort, PowerStatus } from '@/domain/battery' import type { BatteryInfo, BatteryListSort, PowerStatus } from '@/domain/battery'
import { BATTERY_LIST_SORT, BATTERY_LIST_SORT_VALUES, POWER_STATUS, POWER_STATUS_VALUES } from '@/domain/battery' import { BATTERY_LIST_SORT, BATTERY_LIST_SORT_VALUES, POWER_STATUS, POWER_STATUS_VALUES } from '@/domain/battery'
@@ -14,6 +14,7 @@ const pageSizeOptions = [20, 50, 100] as const
type PageSizeOption = (typeof pageSizeOptions)[number] type PageSizeOption = (typeof pageSizeOptions)[number]
const firstPageCursor = '__FIRST_PAGE__' const firstPageCursor = '__FIRST_PAGE__'
const allPowerStatusValue = 'all' const allPowerStatusValue = 'all'
const loadingRowKeys = Array.from({ length: 10 }, (_, index) => `loading-row-${index}`)
const searchFilterSchema = z.preprocess( const searchFilterSchema = z.preprocess(
(value) => (typeof value === 'string' ? value.trim() || undefined : value), (value) => (typeof value === 'string' ? value.trim() || undefined : value),
@@ -314,10 +315,20 @@ function BatteriesPage() {
type="text" type="text"
placeholder="搜索设备名称或编号..." placeholder="搜索设备名称或编号..."
maxLength={100} maxLength={100}
className="pl-9" className="pl-9 pr-9"
value={localSearch} value={localSearch}
onChange={(e) => setLocalSearch(e.target.value)} onChange={(e) => setLocalSearch(e.target.value)}
/> />
{localSearch && (
<button
type="button"
aria-label="清空搜索内容"
onClick={() => setLocalSearch('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
>
<X className="size-4" />
</button>
)}
</div> </div>
</div> </div>
@@ -421,9 +432,9 @@ function BatteriesPage() {
</Card> </Card>
<Card className="overflow-hidden"> <Card className="overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto max-h-[600px]">
<table className={`w-full border-collapse text-left text-sm ${isPlaceholderData ? 'opacity-60' : ''}`}> <table className={`w-full border-collapse text-left text-sm ${isPlaceholderData ? 'opacity-60' : ''}`}>
<thead> <thead className="sticky top-0 z-10 bg-zinc-950/90 backdrop-blur-sm">
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="border-b border-white/5 bg-white/[0.02]"> <tr key={headerGroup.id} className="border-b border-white/5 bg-white/[0.02]">
{headerGroup.headers.map((header) => ( {headerGroup.headers.map((header) => (
@@ -436,15 +447,46 @@ function BatteriesPage() {
</thead> </thead>
<tbody className="divide-y divide-white/[0.02]"> <tbody className="divide-y divide-white/[0.02]">
{isPending && !isPlaceholderData ? ( {isPending && !isPlaceholderData ? (
<tr> loadingRowKeys.map((key) => (
<td colSpan={columns.length} className="h-32 text-center text-zinc-500"> <tr key={key}>
<td className="px-6 py-4">
</td> <Skeleton className="h-5 w-32" />
</tr> <Skeleton className="mt-1.5 h-3 w-24" />
</td>
<td className="px-6 py-4">
<Skeleton className="h-4 w-20" />
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<Skeleton className="h-4 w-8" />
<Skeleton className="h-1.5 w-16 rounded-full" />
</div>
</td>
<td className="px-6 py-4">
<Skeleton className="h-6 w-16 rounded-full" />
</td>
<td className="px-6 py-4">
<Skeleton className="h-4 w-32" />
</td>
</tr>
))
) : data?.items.length === 0 ? ( ) : data?.items.length === 0 ? (
<tr> <tr>
<td colSpan={columns.length} className="h-32 text-center text-zinc-500"> <td colSpan={columns.length} className="h-64">
<EmptyState
icon={<Battery className="size-8" />}
title="未找到符合条件的设备"
description={
hasActiveFilters ? '尝试调整筛选条件或清除筛选以查看更多设备。' : '当前暂无设备数据接入。'
}
action={
hasActiveFilters ? (
<Button onClick={clearFilters}>
<FilterX className="size-4" />
</Button>
) : undefined
}
/>
</td> </td>
</tr> </tr>
) : ( ) : (
+22 -13
View File
@@ -15,7 +15,7 @@ import {
import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent' import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent'
import { orpc } from '@/client/orpc' import { orpc } from '@/client/orpc'
import { MotionCardArticle, MotionCardDiv, MotionHeader, MotionSection } from '@/components/motion' import { MotionCardArticle, MotionCardDiv, MotionHeader, MotionSection } from '@/components/motion'
import { Badge, Card, SectionTitle } from '@/components/ui' import { Badge, Card, EmptyState, SectionTitle } from '@/components/ui'
import type { DashboardSnapshot, DeviceStatus } from '@/domain/battery' import type { DashboardSnapshot, DeviceStatus } from '@/domain/battery'
import { BATTERY_LIST_SORT, DEVICE_STATUS } from '@/domain/battery' import { BATTERY_LIST_SORT, DEVICE_STATUS } from '@/domain/battery'
@@ -270,19 +270,19 @@ function Dashboard() {
<ComposedChart data={chartData} margin={{ top: 24, right: 24, bottom: 8, left: 0 }}> <ComposedChart data={chartData} margin={{ top: 24, right: 24, bottom: 8, left: 0 }}>
<defs> <defs>
<linearGradient id="historyFill" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="historyFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#2DD4BF" stopOpacity={0.15} /> <stop offset="0%" stopColor="#2DD4BF" stopOpacity={0.25} />
<stop offset="100%" stopColor="#2DD4BF" stopOpacity={0} /> <stop offset="100%" stopColor="#2DD4BF" stopOpacity={0} />
</linearGradient> </linearGradient>
<linearGradient id="forecastFill" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="forecastFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#818CF8" stopOpacity={0.15} /> <stop offset="0%" stopColor="#818CF8" stopOpacity={0.25} />
<stop offset="100%" stopColor="#818CF8" stopOpacity={0} /> <stop offset="100%" stopColor="#818CF8" stopOpacity={0} />
</linearGradient> </linearGradient>
</defs> </defs>
<CartesianGrid stroke="#ffffff" strokeOpacity={0.05} vertical={false} /> <CartesianGrid stroke="#ffffff" strokeOpacity={0.05} vertical={false} />
<XAxis dataKey="month" tick={{ fill: '#71717A', fontSize: 11 }} axisLine={false} tickLine={false} /> <XAxis dataKey="month" tick={{ fill: '#A1A1AA', fontSize: 12 }} axisLine={false} tickLine={false} />
<YAxis <YAxis
domain={[(min: number) => Math.floor(min) - 2, (max: number) => Math.ceil(max) + 2]} domain={[(min: number) => Math.floor(min) - 2, (max: number) => Math.ceil(max) + 2]}
tick={{ fill: '#71717A', fontSize: 11 }} tick={{ fill: '#A1A1AA', fontSize: 12 }}
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
tickFormatter={(v: number) => `${v}%`} tickFormatter={(v: number) => `${v}%`}
@@ -295,20 +295,21 @@ function Dashboard() {
borderRadius: 8, borderRadius: 8,
fontSize: 13, fontSize: 13,
color: '#F4F4F5', color: '#F4F4F5',
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.5)',
}} }}
itemStyle={{ color: '#A1A1AA' }} itemStyle={{ color: '#E4E4E7', fontWeight: 500 }}
formatter={formatChartTooltip} formatter={formatChartTooltip}
labelStyle={{ color: '#71717A', marginBottom: 4 }} labelStyle={{ color: '#A1A1AA', marginBottom: 6 }}
/> />
<ReferenceLine <ReferenceLine
y={85} y={85}
stroke="#F87171" stroke="#F87171"
strokeOpacity={0.4} strokeOpacity={0.6}
strokeDasharray="4 4" strokeDasharray="4 4"
label={{ label={{
value: '85% 预警线', value: '85% 预警线',
fill: '#F87171', fill: '#F87171',
fontSize: 11, fontSize: 12,
position: 'right', position: 'right',
}} }}
/> />
@@ -358,8 +359,12 @@ function Dashboard() {
</ComposedChart> </ComposedChart>
</ResponsiveContainer> </ResponsiveContainer>
) : ( ) : (
<div className="flex h-full items-center justify-center rounded-xl border border-dashed border-white/10 bg-white/[0.02] text-sm text-[#71717A]"> <div className="flex h-full items-center justify-center rounded-xl border border-dashed border-white/10 bg-white/[0.02]">
<EmptyState
icon={<TrendingDown className="size-8" />}
title="暂无健康趋势数据"
description="当前设备数据不足以生成可靠的健康趋势预测,请等待系统收集更多数据。"
/>
</div> </div>
)} )}
</div> </div>
@@ -590,8 +595,12 @@ function Dashboard() {
)) ))
) : ( ) : (
<tr> <tr>
<td colSpan={8} className="px-6 py-8 text-center text-[#71717A]"> <td colSpan={8} className="px-6 py-12">
<EmptyState
icon={<Activity className="size-8" />}
title="暂无设备数据"
description="当前没有可用于健康分析的设备记录。"
/>
</td> </td>
</tr> </tr>
)} )}