从零实现 Next.js 设备状态监控墙

星期一, 7月 6, 2026 | 7分钟阅读 | 更新于 星期一, 7月 6, 2026

@

上一篇介绍了 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.tsxapp/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.tsxcard.tsxtooltip.tsxdialog.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 报错htmlsuppressHydrationWarning,主题切换按钮用 mounted 守卫
localStorage 服务端报错放进 useEffect,挂载后才读
轮询定时器反复重建useRef 存最新值,useEffect 依赖只留 pollEnabled
Tailwind v4 写法变了@import "tailwindcss" 而非 @tailwind;暗色用 @custom-variant dark
后端接口两个端点结构不同封装时 json.data ?? json 统一解包

总结

这个项目的精髓在两处:

  1. 轮询调度:一个 1 秒定时器 + 每台设备的到期时间表,实现「全局间隔 + 单设备自定义间隔」+ 倒计时显示。这套模式适用于任何「多个对象各自独立定时刷新」的场景。

  2. 状态驱动的 UI:一份 statusConfig 配置表统管颜色/图标/文案,方块的所有视觉变化都由 status 字段驱动,加新状态只改配置表。

技术栈层面,它就是上一篇那套现代栈的实战落地:Next.js App Router 做单页应用、shadcn/ui 做组件、next-themes 做暗色、Tailwind v4 做样式、lucide 做图标。

© 2026 My Blog

🌱 Powered by Hugo with theme Dream.