feat(ui): 添加克制页面动效

This commit is contained in:
2026-05-12 00:30:16 +08:00
parent 602f969117
commit 2dabbd1281
3 changed files with 164 additions and 36 deletions
+126
View File
@@ -0,0 +1,126 @@
import { motion, useReducedMotion } from 'motion/react'
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
export function useMotionConfig() {
const shouldReduceMotion = useReducedMotion()
return { shouldReduceMotion }
}
export function MotionHeader({
children,
delay = 0,
className,
...props
}: { children: ReactNode; delay?: number } & ComponentPropsWithoutRef<typeof motion.header>) {
const { shouldReduceMotion } = useMotionConfig()
return (
<motion.header
initial={{ opacity: 0, y: shouldReduceMotion ? 0 : 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay, ease: [0.25, 0.1, 0.25, 1] }}
className={className}
{...props}
>
{children}
</motion.header>
)
}
export function MotionSection({
children,
delay = 0,
className,
...props
}: { children: ReactNode; delay?: number } & ComponentPropsWithoutRef<typeof motion.section>) {
const { shouldReduceMotion } = useMotionConfig()
return (
<motion.section
initial={{ opacity: 0, y: shouldReduceMotion ? 0 : 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay, ease: [0.25, 0.1, 0.25, 1] }}
className={className}
{...props}
>
{children}
</motion.section>
)
}
export function MotionDiv({
children,
delay = 0,
className,
...props
}: { children: ReactNode; delay?: number } & ComponentPropsWithoutRef<typeof motion.div>) {
const { shouldReduceMotion } = useMotionConfig()
return (
<motion.div
initial={{ opacity: 0, y: shouldReduceMotion ? 0 : 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay, ease: [0.25, 0.1, 0.25, 1] }}
className={className}
{...props}
>
{children}
</motion.div>
)
}
export function MotionCardArticle({
children,
className,
...props
}: { children: ReactNode } & ComponentPropsWithoutRef<typeof motion.article>) {
const { shouldReduceMotion } = useMotionConfig()
return (
<motion.article
whileHover={shouldReduceMotion ? {} : { y: -2 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className={className}
{...props}
>
{children}
</motion.article>
)
}
export function MotionCardDiv({
children,
className,
...props
}: { children: ReactNode } & ComponentPropsWithoutRef<typeof motion.div>) {
const { shouldReduceMotion } = useMotionConfig()
return (
<motion.div
whileHover={shouldReduceMotion ? {} : { y: -2 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className={className}
{...props}
>
{children}
</motion.div>
)
}
export function MotionTableRow({
children,
className,
...props
}: { children: ReactNode } & ComponentPropsWithoutRef<typeof motion.tr>) {
return (
<motion.tr
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className={className}
{...props}
>
{children}
</motion.tr>
)
}
+13 -12
View File
@@ -5,6 +5,7 @@ import { ArrowLeft, Battery, BatteryCharging, BatteryLow, FilterX, Search, Zap }
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 { Badge, Button, Card, Input, SectionTitle, Select } from '@/components/ui' import { Badge, Button, Card, Input, SectionTitle, Select } 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'
@@ -240,7 +241,7 @@ function BatteriesPage() {
</div> </div>
<div className="relative z-10 mx-auto max-w-7xl px-6 py-8"> <div className="relative z-10 mx-auto max-w-7xl px-6 py-8">
<header> <MotionHeader>
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between"> <div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div> <div>
<Badge variant="info" className="mb-4"> <Badge variant="info" className="mb-4">
@@ -265,28 +266,28 @@ function BatteriesPage() {
</div> </div>
<dl className="mt-8 grid grid-cols-1 gap-4 sm:grid-cols-3"> <dl className="mt-8 grid grid-cols-1 gap-4 sm:grid-cols-3">
<Card className="p-5"> <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"> <dt className="flex items-center gap-2 text-xs font-medium text-zinc-500">
<Battery className="size-4 text-zinc-400" /> <Battery className="size-4 text-zinc-400" />
</dt> </dt>
<dd className="mt-3 text-3xl font-light tabular-nums text-white">{data?.total ?? '-'}</dd> <dd className="mt-3 text-3xl font-light tabular-nums text-white">{data?.total ?? '-'}</dd>
</Card> </MotionCardDiv>
<Card className="p-5"> <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"> <dt className="flex items-center gap-2 text-xs font-medium text-zinc-500">
<BatteryLow className="size-4 text-red-400" /> <BatteryLow className="size-4 text-red-400" />
</dt> </dt>
<dd className="mt-3 text-3xl font-light tabular-nums text-red-300">{data?.lowPower ?? '-'}</dd> <dd className="mt-3 text-3xl font-light tabular-nums text-red-300">{data?.lowPower ?? '-'}</dd>
</Card> </MotionCardDiv>
<Card className="p-5"> <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"> <dt className="flex items-center gap-2 text-xs font-medium text-zinc-500">
<BatteryCharging className="size-4 text-teal-300" /> <BatteryCharging className="size-4 text-teal-300" />
</dt> </dt>
<dd className="mt-3 text-3xl font-light tabular-nums text-teal-300">{data?.charging ?? '-'}</dd> <dd className="mt-3 text-3xl font-light tabular-nums text-teal-300">{data?.charging ?? '-'}</dd>
</Card> </MotionCardDiv>
</dl> </dl>
</header> </MotionHeader>
<section className="mt-10"> <MotionSection delay={0.1} className="mt-10">
<Card className="mb-6 p-5"> <Card className="mb-6 p-5">
<div className="mb-5 flex flex-wrap items-center justify-between gap-3"> <div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<SectionTitle <SectionTitle
@@ -447,13 +448,13 @@ function BatteriesPage() {
</tr> </tr>
) : ( ) : (
table.getRowModel().rows.map((row) => ( table.getRowModel().rows.map((row) => (
<tr key={row.id} className="transition-colors hover:bg-white/[0.02]"> <MotionTableRow key={row.id} className="transition-colors hover:bg-white/[0.02]">
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-6 py-4 whitespace-nowrap"> <td key={cell.id} className="px-6 py-4 whitespace-nowrap">
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
</td> </td>
))} ))}
</tr> </MotionTableRow>
)) ))
)} )}
</tbody> </tbody>
@@ -479,7 +480,7 @@ function BatteriesPage() {
</Button> </Button>
</div> </div>
</div> </div>
</section> </MotionSection>
</div> </div>
</main> </main>
) )
+25 -24
View File
@@ -14,6 +14,7 @@ import {
} from 'recharts' } from 'recharts'
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 { Badge, Card, SectionTitle } from '@/components/ui' import { Badge, Card, 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'
@@ -147,7 +148,7 @@ function Dashboard() {
<div className="relative z-10 mx-auto max-w-[1400px] px-6 pb-24 pt-12 lg:px-12"> <div className="relative z-10 mx-auto max-w-[1400px] px-6 pb-24 pt-12 lg:px-12">
{/* Header */} {/* Header */}
<header className="animate-fade-up mb-12 flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between"> <MotionHeader className="mb-12 flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl"> <div className="max-w-3xl">
<Badge variant="info" className="mb-4"> <Badge variant="info" className="mb-4">
<Activity className="size-3.5" /> <Activity className="size-3.5" />
@@ -165,18 +166,18 @@ function Dashboard() {
<ArrowRight className="size-3" /> <ArrowRight className="size-3" />
</Link> </Link>
</div> </div>
</header> </MotionHeader>
{/* Executive Summary */} {/* Executive Summary */}
<section className="animate-fade-up delay-100 mb-12 rounded-xl border border-teal-900/30 bg-teal-950/10 p-6"> <MotionSection delay={0.1} className="mb-12 rounded-xl border border-teal-900/30 bg-teal-950/10 p-6">
<h2 className="mb-3 text-sm font-medium text-teal-400"></h2> <h2 className="mb-3 text-sm font-medium text-teal-400"></h2>
<p className="text-base leading-relaxed text-[#A1A1AA]">{executiveSummary}</p> <p className="text-base leading-relaxed text-[#A1A1AA]">{executiveSummary}</p>
</section> </MotionSection>
{/* Primary KPI Row */} {/* Primary KPI Row */}
<section className="animate-fade-up delay-200 mb-12 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-5"> <MotionSection delay={0.2} className="mb-12 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-5">
{/* Hero KPI */} {/* Hero KPI */}
<article className="relative overflow-hidden rounded-2xl border border-white/10 bg-white/[0.03] p-8 lg:col-span-2"> <MotionCardArticle className="relative overflow-hidden rounded-2xl border border-white/10 bg-white/[0.03] p-8 lg:col-span-2">
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-teal-500/50 to-transparent" /> <div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-teal-500/50 to-transparent" />
<p className="text-sm font-medium text-[#A1A1AA]"></p> <p className="text-sm font-medium text-[#A1A1AA]"></p>
<div className="mt-4 flex items-baseline gap-2"> <div className="mt-4 flex items-baseline gap-2">
@@ -191,10 +192,10 @@ function Dashboard() {
<span className="text-[#71717A]">|</span> <span className="text-[#71717A]">|</span>
<span className="text-[#A1A1AA]"> {totalDevices} </span> <span className="text-[#A1A1AA]"> {totalDevices} </span>
</div> </div>
</article> </MotionCardArticle>
{/* Regular KPIs */} {/* Regular KPIs */}
<article className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-6 transition-colors hover:border-white/10"> <MotionCardArticle className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-6 transition-colors hover:border-white/10">
<p className="text-sm text-[#A1A1AA]">30 </p> <p className="text-sm text-[#A1A1AA]">30 </p>
<div className="mt-3 flex items-baseline gap-1"> <div className="mt-3 flex items-baseline gap-1">
<h2 className="text-4xl font-light tabular-nums text-white">{formatPercent(avgSoh30d)}</h2> <h2 className="text-4xl font-light tabular-nums text-white">{formatPercent(avgSoh30d)}</h2>
@@ -204,9 +205,9 @@ function Dashboard() {
<span className="text-red-400"></span> <span className="text-red-400"></span>
<span className="tabular-nums text-[#A1A1AA]">{formatDelta(avgSoh, avgSoh30d)}</span> <span className="tabular-nums text-[#A1A1AA]">{formatDelta(avgSoh, avgSoh30d)}</span>
</div> </div>
</article> </MotionCardArticle>
<article className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-6 transition-colors hover:border-white/10"> <MotionCardArticle className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-6 transition-colors hover:border-white/10">
<p className="text-sm text-[#A1A1AA]">90 </p> <p className="text-sm text-[#A1A1AA]">90 </p>
<div className="mt-3 flex items-baseline gap-1"> <div className="mt-3 flex items-baseline gap-1">
<h2 className="text-4xl font-light tabular-nums text-white">{formatPercent(avgSoh90d)}</h2> <h2 className="text-4xl font-light tabular-nums text-white">{formatPercent(avgSoh90d)}</h2>
@@ -216,9 +217,9 @@ function Dashboard() {
<span className="text-red-400"></span> <span className="text-red-400"></span>
<span className="tabular-nums text-[#A1A1AA]">{formatDelta(avgSoh, avgSoh90d)}</span> <span className="tabular-nums text-[#A1A1AA]">{formatDelta(avgSoh, avgSoh90d)}</span>
</div> </div>
</article> </MotionCardArticle>
<article className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-6 transition-colors hover:border-white/10"> <MotionCardArticle className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-6 transition-colors hover:border-white/10">
<p className="text-sm text-[#A1A1AA]"></p> <p className="text-sm text-[#A1A1AA]"></p>
<div className="mt-3 flex items-baseline gap-1"> <div className="mt-3 flex items-baseline gap-1">
<h2 className="text-4xl font-light tabular-nums text-white">{warningCount}</h2> <h2 className="text-4xl font-light tabular-nums text-white">{warningCount}</h2>
@@ -230,14 +231,14 @@ function Dashboard() {
{totalDevices > 0 ? ((warningCount / totalDevices) * 100).toFixed(1) : 0}% {totalDevices > 0 ? ((warningCount / totalDevices) * 100).toFixed(1) : 0}%
</span> </span>
</div> </div>
</article> </MotionCardArticle>
</section> </MotionSection>
{/* Divider */} {/* Divider */}
<hr className="my-12 border-white/5" /> <hr className="my-12 border-white/5" />
{/* Health trend chart */} {/* Health trend chart */}
<section className="animate-fade-up delay-300 mb-12"> <MotionSection delay={0.3} className="mb-12">
<Card className="p-8"> <Card className="p-8">
<header className="mb-8 flex flex-wrap items-end justify-between gap-4"> <header className="mb-8 flex flex-wrap items-end justify-between gap-4">
<SectionTitle <SectionTitle
@@ -359,10 +360,10 @@ function Dashboard() {
)} )}
</div> </div>
</Card> </Card>
</section> </MotionSection>
{/* Two-column grid */} {/* Two-column grid */}
<section className="animate-fade-up delay-400 mb-12 grid grid-cols-1 gap-8 lg:grid-cols-2"> <MotionSection delay={0.4} className="mb-12 grid grid-cols-1 gap-8 lg:grid-cols-2">
{/* Left Column */} {/* Left Column */}
<div className="space-y-8"> <div className="space-y-8">
{/* Risk Distribution */} {/* Risk Distribution */}
@@ -504,13 +505,13 @@ function Dashboard() {
</div> </div>
</div> </div>
</div> </div>
</section> </MotionSection>
{/* Divider */} {/* Divider */}
<hr className="my-12 border-white/5" /> <hr className="my-12 border-white/5" />
{/* Device Table */} {/* Device Table */}
<section className="animate-fade-up delay-500 mb-12"> <MotionSection delay={0.5} className="mb-12">
<div className="mb-6 flex items-end justify-between"> <div className="mb-6 flex items-end justify-between">
<div> <div>
<h3 className="text-xl font-medium text-white"></h3> <h3 className="text-xl font-medium text-white"></h3>
@@ -594,17 +595,17 @@ function Dashboard() {
</table> </table>
</div> </div>
</Card> </Card>
</section> </MotionSection>
{/* Bottom Row */} {/* Bottom Row */}
<section className="animate-fade-up delay-500 grid grid-cols-1 gap-8 lg:grid-cols-3"> <MotionSection delay={0.5} className="grid grid-cols-1 gap-8 lg:grid-cols-3">
{/* Strategy Cards */} {/* Strategy Cards */}
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<h3 className="mb-6 text-lg font-medium text-white"></h3> <h3 className="mb-6 text-lg font-medium text-white"></h3>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
{strategies.length > 0 ? ( {strategies.length > 0 ? (
strategies.map((item, index) => ( strategies.map((item, index) => (
<div <MotionCardDiv
key={item.name} key={item.name}
className="relative overflow-hidden rounded-xl border border-white/[0.06] bg-white/[0.02] p-5" className="relative overflow-hidden rounded-xl border border-white/[0.06] bg-white/[0.02] p-5"
> >
@@ -617,7 +618,7 @@ function Dashboard() {
<span>: {item.scope}</span> <span>: {item.scope}</span>
<span>: {item.eta}</span> <span>: {item.eta}</span>
</div> </div>
</div> </MotionCardDiv>
)) ))
) : ( ) : (
<div className="text-sm text-[#71717A] col-span-2"></div> <div className="text-sm text-[#71717A] col-span-2"></div>
@@ -649,7 +650,7 @@ function Dashboard() {
</div> </div>
</div> </div>
</div> </div>
</section> </MotionSection>
</div> </div>
</main> </main>
) )