上一篇介绍了 Next.js 现代技术栈,这篇动手实操:从零开始一步步实现一个「精准仓设备状态监控墙」——一个用网格方块实时展示上百台行车设备健康状态的大屏页面。本文对照我工作区里 frontend-next 项目实现,把每一步拆开讲清楚。
项目效果与技术栈
最终效果:一个深色大屏,每个设备是一个会脉冲呼吸的彩色方块(绿=正常、黄=异常、红=无数据),鼠标悬浮显示详情,可分组、搜索、调整每台设备的轮询间隔。
技术栈:
Next.js 16 (App Router) + React 19 + TypeScript
Tailwind CSS v4 + shadcn/ui(Radix)
next-themes(暗色模式)
lucide-react(图标)
next/font(Geist 字体)
核心难点不是 UI,而是前端轮询调度:要支持「全局轮询间隔」+「单台设备自定义间隔」,还要把每台设备下次轮询的倒计时显示在方块上。
第一步:初始化项目
npx create-next-app@latest device-wall \
--typescript --tailwind --app \
--use-npm --no-src-dir
cd device-wall
关键选项:开 TypeScript、Tailwind、App Router、不要 src/ 目录(让 app/ 在根目录,路径更短)。
这会生成基础结构。app/page.tsx 和 app/layout.tsx 是入口,app/globals.css 是全局样式。
第二步:初始化 shadcn/ui
shadcn/ui 不是 npm 包,是「把组件源码拷进你项目」:
npx shadcn@latest init # 初始化配置(选默认风格、CSS 变量)
npx shadcn@latest add button card tooltip dialog
执行后 components/ui/ 下会出现 button.tsx、card.tsx、tooltip.tsx、dialog.tsx,源码归你,随便改。还会生成 lib/utils.ts(提供 cn() 合并 class 的工具函数)。
第三步:字体与主题(layout.tsx)
app/layout.tsx 是整个应用的骨架,负责加载字体、套全局 Provider。
import { Geist, Geist_Mono } from "next/font/google"
import { ThemeProvider } from "@/components/theme-provider"
import { TooltipProvider } from "@/components/ui/tooltip"
import "./globals.css"
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] })
const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] })
export const metadata = {
title: "精准仓设备状态监控墙",
description: "实时监控所有行车设备的数据状态",
}
export default function RootLayout({ children }) {
return (
<html lang="zh-CN"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
suppressHydrationWarning>
<body className="min-h-full bg-background text-foreground">
<ThemeProvider>
<TooltipProvider delayDuration={200}>
{children}
</TooltipProvider>
</ThemeProvider>
</body>
</html>
)
}
几个要点:
next/font在构建时把字体下载到本地,零运行时请求,还自动消除字体加载的布局抖动- 字体挂成 CSS 变量
--font-geist-sans,供 Tailwind 使用 suppressHydrationWarning必须加:暗色模式会在<html>加 class,服务端和客户端不一致会报 hydration 警告TooltipProvider套在最外层,所有 Tooltip 共享一个 Provider
第四步:暗色模式(next-themes)
components/theme-provider.tsx:
"use client"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({ children }) {
return (
<NextThemesProvider
attribute="class" // 通过 class="dark" 切换
defaultTheme="dark" // 默认暗色(监控大屏通常用暗色)
enableSystem
disableTransitionOnChange>
{children}
</NextThemesProvider>
)
}
components/theme-toggle.tsx(右上角切换按钮):
"use client"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), []) // 防 hydration 不匹配
if (!mounted) return <Button variant="ghost" size="icon" />
return (
<Button variant="ghost" size="icon" onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
)
}
坑:mounted 这步不能省。next-themes 在服务端不知道用户主题,直接渲染图标会导致 hydration mismatch,所以先渲染占位,挂载后再渲染真实图标。
第五步:全局样式与设计令牌(globals.css)
Tailwind v4 用 @import "tailwindcss" 代替了旧的 @tailwind 指令。shadcn/ui 的色彩体系靠 CSS 变量:
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0); /* 亮色背景 */
--foreground: oklch(0.145 0 0);
/* ... primary / muted / border / destructive 等 */
}
.dark {
--background: oklch(0.145 0 0); /* 暗色背景 */
--foreground: oklch(0.985 0 0);
/* ... */
}
设备状态墙还需要自定义动画(方块呼吸 + 入场动画):
@keyframes pulse-status {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
@keyframes fade-in-up {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-pulse-status { animation: pulse-status 2s ease-in-out infinite; }
.animate-fade-in-up { animation: fade-in-up 0.4s ease-out; }
呼吸动画让方块像心跳一样闪烁,入场动画配合 animationDelay 让方块依次出现(瀑布流效果)。
第六步:API 代理配置(next.config.ts)
前端请求 /api/...,但后端 Spring Boot 跑在 localhost:9091。用 Next.js 的 rewrites 做代理,避免跨域:
import type { NextConfig } from "next"
const nextConfig: NextConfig = {
async rewrites() {
return [
{ source: "/api/:path*", destination: "http://localhost:9091/api/:path*" },
]
},
}
export default nextConfig
这样前端写 fetch("/api/devices/status") 就行,Next.js 开发服务器会转发到 9091。生产部署时由 Nginx 做同样的代理。
第七步:定义数据类型(types/index.ts)
先把后端返回的数据结构用 TypeScript 描述清楚,这是类型安全的基础:
export type DeviceStatus = "healthy" | "warning" | "error" | "unknown"
export interface DeviceInfo {
id: string
carName?: string
warehouseName?: string
warehouseCode?: string
mqttClientName?: string
status: DeviceStatus
lastSeen?: string
errorMessage?: string
timeDiffSeconds?: number // 设备时间偏差(秒)
lastData?: {
tm: string; x: string; y: string; z: string; t: string; status: string
} | null
}
四个状态:healthy(正常)、warning(数据异常)、error(无数据)、unknown(未知)。
第八步:API 封装(lib/api.ts)
把所有后端请求集中到一处,方便维护:
import type { DeviceInfo } from "@/types"
const API_BASE = "/api"
export async function fetchDeviceStatuses(): Promise<DeviceInfo[]> {
const res = await fetch(`${API_BASE}/devices/status`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
export async function fetchDeviceStatus(clientId: string): Promise<DeviceInfo> {
const res = await fetch(`${API_BASE}/devices/status/${encodeURIComponent(clientId)}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const json = await res.json()
return json.data ?? json // 单设备端点有 {msg,code,data} 包装
}
注意:两个端点返回结构不同——全量端点返回裸数组,单设备端点包了一层 {msg, code, data},这里要 json.data ?? json 解包。
第九步:核心组件 DeviceWall(状态 + 轮询)
这是整个项目的灵魂。先看状态定义:
"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { fetchDeviceStatuses, fetchDeviceStatus } from "@/lib/api"
export function DeviceWall() {
const [devices, setDevices] = useState<DeviceInfo[]>([]) // 设备列表
const [isLoading, setIsLoading] = useState(false)
const [pollEnabled, setPollEnabled] = useState(true) // 轮询开关
const [pollSeconds, setPollSeconds] = useState(10) // 全局轮询间隔
const [pollOverrides, setPollOverrides] = useState<Record<string, number>>({}) // 单设备自定义间隔
const [error, setError] = useState<string | null>(null)
// ... 分组、搜索、统计等状态
初始加载
useEffect(() => {
const init = async () => {
setIsLoading(true)
try {
const data = await fetchDeviceStatuses()
setDevices(data)
} catch (err) {
setError(`连接失败: ${err.message}`)
} finally {
setIsLoading(false)
}
}
init()
}, [])
轮询调度器(最精妙的部分)
难点:要支持每台设备不同间隔,还要显示倒计时。项目的做法是一个 1 秒的统一定时器 + 每台设备的下次到期时间表:
const devicesRef = useRef(devices) // 用 ref 读最新值,避免重渲染
devicesRef.current = devices
const pollSecondsRef = useRef(pollSeconds)
pollSecondsRef.current = pollSeconds
const nextDueRef = useRef<Record<string, number>>({}) // 设备ID → 下次到期时间戳
useEffect(() => {
if (!pollEnabled) return
const id = setInterval(() => {
const now = Date.now()
for (const device of devicesRef.current) {
const due = nextDueRef.current[device.id] ?? 0
if (now >= due) {
// 到期了,单独请求这台设备
const effSec = pollOverrides[device.id] ?? pollSecondsRef.current
nextDueRef.current[device.id] = now + effSec * 1000 // 排下次
fetchDeviceStatus(device.id)
.then(data => setDevices(prev => prev.map(d => d.id === data.id ? data : d)))
.catch(err => console.error(`Poll ${device.id} failed:`, err))
}
}
}, 1000) // 每秒扫描一次
return () => clearInterval(id)
}, [pollEnabled])
为什么这样设计:
- 用一个 1 秒定时器扫所有设备,而不是每台设备各开一个
setInterval(设备多了定时器爆炸) - 用
useRef读最新状态,这样useEffect的依赖只有pollEnabled,定时器不会因为状态变化反复重建 - 到期时间表
nextDueRef也用来算倒计时:方块显示「下次轮询:3s」就是dueAtMs - nowMs
分组、搜索、统计
用 useMemo 派生数据,保证性能:
// 先过滤
const filteredDevices = useMemo(() =>
devices.filter(d => /* 名称、仓库匹配 */)
, [devices, searchName, searchWarehouse])
// 再分组(按仓库 / 按状态 / 不分组)
const groupedDevices = useMemo(() => {
const groups = {}
for (const d of filteredDevices) {
const key = d.warehouseName || "未分类"
;(groups[key] ||= []).push(d)
}
// 每组内异常排前面
return groups
}, [filteredDevices, groupBy])
// 统计
const stats = useMemo(() => ({
total: filteredDevices.length,
healthy: filteredDevices.filter(d => d.status === "healthy").length,
warning: filteredDevices.filter(d => d.status === "warning").length,
error: filteredDevices.filter(d => d.status === "error").length,
}), [filteredDevices])
排序技巧:异常设备排最前面。定义 statusOrder = { error: 0, warning: 1, healthy: 2 },排序时 a.order - b.order。
第十步:单个设备方块 DeviceCell
每个方块的颜色、图标、悬浮提示都由状态决定。用一个配置表统一管理:
const statusConfig = {
healthy: { bg: "bg-emerald-500", icon: CheckCircle2, label: "正常", glow: "shadow-[0_0_12px_rgba(34,197,94,0.4)]" },
warning: { bg: "bg-amber-500", icon: AlertTriangle, label: "数据异常", glow: "shadow-[0_0_12px_rgba(245,158,11,0.4)]" },
error: { bg: "bg-red-500", icon: WifiOff, label: "无数据", glow: "shadow-[0_0_12px_rgba(239,68,68,0.4)]" },
unknown: { bg: "bg-gray-600", icon: Clock, label: "未知", glow: "" },
}
方块本体:
<Tooltip>
<TooltipTrigger asChild>
<div
onClick={() => onClick?.(device)}
className={`
relative aspect-square rounded-md border cursor-pointer
transition-all duration-300
${config.bg} ${config.border} hover:scale-110
animate-fade-in-up
${device.status !== "unknown" ? "animate-pulse-status" : ""}
`}
style={{ animationDelay: `${index * 30}ms` }} // 错开入场时间
>
{/* 方块内显示仓库名、设备名 */}
<span className="text-white font-extrabold">{device.warehouseName}</span>
</div>
</TooltipTrigger>
<TooltipContent>{/* 详情:坐标、重量、时间偏差、倒计时 */}</TooltipContent>
</Tooltip>
亮点:
animationDelay: index * 30ms:方块按顺序错开 30ms 入场,形成瀑布流animate-pulse-status:呼吸动画,让方块像在「跳动」hover:scale-110+glow:悬浮放大并发光- 自定义轮询的方块右上角加个黄点标记
第十一步:单设备自定义轮询间隔
这是项目的特色功能。点击方块弹窗,设置这台设备单独的轮询间隔,存到 localStorage:
const LOCAL_STORAGE_KEY = "device-poll-overrides"
function loadOverrides() {
try { return JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) || "{}") }
catch { return {} }
}
useEffect(() => {
setPollOverrides(loadOverrides()) // 挂载后从本地恢复
}, [])
const handleSavePollOverride = (deviceId, seconds) => {
setPollOverrides(prev => {
const next = { ...prev }
if (seconds === null) delete next[deviceId]
else next[deviceId] = seconds
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(next))
return next
})
nextDueRef.current[deviceId] = 0 // 立即触发一次
}
轮询时取「自定义值 ?? 全局值」:pollOverrides[device.id] ?? pollSeconds。
坑:localStorage 只在浏览器有,所以读取要放在 useEffect 里(挂载后),否则服务端渲染报错。
第十二步:组装页面
app/page.tsx 极简:
import { DeviceWall } from "@/components/device-wall/device-wall"
export default function Home() {
return <DeviceWall />
}
DeviceWall 内部用 Tailwind grid 渲染网格,响应式列数:
<div className="grid grid-cols-5 sm:grid-cols-8 lg:grid-cols-10 gap-1.5">
{groupDevices.map(device => <DeviceCell key={device.id} device={device} ... />)}
</div>
页面顶部是统计卡片(总数/正常/异常/无数据),中间是控制栏(刷新、停止轮询、分组、搜索、轮询间隔配置),底部是设备网格。
第十三步:运行与调试
# 终端 1:启动后端(Spring Boot,端口 9091)
cd tcp-server && mvn spring-boot:run
# 终端 2:启动前端(Next.js,端口 3000)
cd frontend-next && npm run dev
打开 http://localhost:3000,前端请求 /api/devices/status 会被 next.config.ts 代理到 9091。
没有后端怎么调试? 临时改 lib/api.ts,返回 mock 数据即可:
export async function fetchDeviceStatuses() {
return [
{ id: "001", warehouseName: "A库", status: "healthy", lastSeen: new Date().toISOString() },
{ id: "002", warehouseName: "B库", status: "warning" },
{ id: "003", warehouseName: "C库", status: "error" },
]
}
遇到的坑总结
| 坑 | 解决方案 |
|---|---|
| 暗色模式 hydration 报错 | html 加 suppressHydrationWarning,主题切换按钮用 mounted 守卫 |
localStorage 服务端报错 | 放进 useEffect,挂载后才读 |
| 轮询定时器反复重建 | 用 useRef 存最新值,useEffect 依赖只留 pollEnabled |
| Tailwind v4 写法变了 | 用 @import "tailwindcss" 而非 @tailwind;暗色用 @custom-variant dark |
| 后端接口两个端点结构不同 | 封装时 json.data ?? json 统一解包 |
总结
这个项目的精髓在两处:
轮询调度:一个 1 秒定时器 + 每台设备的到期时间表,实现「全局间隔 + 单设备自定义间隔」+ 倒计时显示。这套模式适用于任何「多个对象各自独立定时刷新」的场景。
状态驱动的 UI:一份
statusConfig配置表统管颜色/图标/文案,方块的所有视觉变化都由status字段驱动,加新状态只改配置表。
技术栈层面,它就是上一篇那套现代栈的实战落地:Next.js App Router 做单页应用、shadcn/ui 做组件、next-themes 做暗色、Tailwind v4 做样式、lucide 做图标。