赋予数据形态:从 API 到 UI,构建状态驱动的后台页面

目录

  • [一、 页面本质:API 的可视化执行器](#一、 页面本质:API 的可视化执行器)
  • [二、 门户基建:构建左右结构的"管理后台骨架"](#二、 门户基建:构建左右结构的“管理后台骨架”)
    • [💡 架构认知:Layout 的局部刷新机制](#💡 架构认知:Layout 的局部刷新机制)
  • [三、 页面结构:把业务装进内容区](#三、 页面结构:把业务装进内容区)
  • [四、 核心容器:UserTable(系统的真正入口)](#四、 核心容器:UserTable(系统的真正入口))
  • [五、 引入 shadcn/ui:搭建企业级表格基础设施](#五、 引入 shadcn/ui:搭建企业级表格基础设施)
  • [六、 构建与绑定 UI 视图](#六、 构建与绑定 UI 视图)
    • [6.1 拼装完整的 UserTable 视图](#6.1 拼装完整的 UserTable 视图)
  • [七、 全景透视:完整的数据流模型](#七、 全景透视:完整的数据流模型)
  • 最终效果
  • [🚀 总结与下一步预告](#🚀 总结与下一步预告)

在上一讲中,我们已经成功搭建了底层的数据引擎,并跑通了支持分页、搜索、状态筛选和排序的用户列表 API。

但到这里,我们的工作其实只完成了一半。在真实的业务系统中,一个 API 的价值不在于它"能否返回一份完美的 JSON 数据",而在于它能否高效驱动一个可交互的 UI 系统(Table + Filter + Form)

本节,我们将完成关键的跨越:从"底层 API"走向"可视化交互",构建一个现代化的数据驱动页面。


一、 页面本质:API 的可视化执行器

如果你熟悉低代码平台,你会发现构建一个列表页面的心智模型非常固化:

  1. 配置系统菜单(导航)
  2. 拖拽绑定表格组件到内容区
  3. 勾选需要显示的筛选条件
  4. 开启自动分页加载

在 Next.js 的全栈世界里,虽然没有了可视化的拖拽面板,但底层的逻辑编排是一模一样的

布局壳子提供导航 → API 层提供弹药 → 数据状态作为中枢 → 表格组件负责呈现


二、 门户基建:构建左右结构的"管理后台骨架"

在写具体的业务页面前,我们首先要解决"壳子"的问题。一个标准的管理后台,通常是左侧菜单、右侧内容的结构。

在 Next.js App Router 中,这个能力由 layout.tsx 完美承担。我们在 app/(admin)/layout.tsx 中定义这个全局骨架:

tsx 复制代码
// app/(admin)/layout.tsx
import Link from "next/link"

export default function AdminLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="flex h-screen overflow-hidden bg-gray-50">
      
      {/* 左侧:系统导航菜单 (Sidebar) */}
      <aside className="w-64 bg-white border-r border-gray-200 shadow-sm flex flex-col">
        <div className="h-16 flex items-center px-6 border-b border-gray-100">
          <h1 className="text-xl font-bold text-gray-800">Admin Portal</h1>
        </div>
        
        {/* 后续可以将菜单抽离为配置项,动态渲染 */}
        <nav className="flex-1 p-4 space-y-2 overflow-y-auto">
          <Link 
            href="/dashboard" 
            className="block px-4 py-2.5 text-sm font-medium text-gray-600 rounded-lg hover:bg-gray-100 transition-colors"
          >
            仪表盘
          </Link>
          <Link 
            href="/users" 
            className="block px-4 py-2.5 text-sm font-medium text-white bg-black rounded-lg shadow-sm"
          >
            人员管理
          </Link>
        </nav>
      </aside>

      {/* 右侧:动态内容渲染区 (Main Content) */}
      <main className="flex-1 h-screen overflow-y-auto relative">
        {/* 这里的 children 就会根据左侧点击的菜单,自动替换为对应的 Page 组件 */}
        <div className="p-8">
          {children}
        </div>
      </main>

    </div>
  )
}

💡 架构认知:Layout 的局部刷新机制

在传统的 SPA 或低代码平台中,点击左侧菜单往往意味着页面的"路由切换"或"iframe 刷新"。而在 Next.js 中,由于有了 layout.tsx,当你点击菜单栏的 <Link> 时,左侧的导航栏不会重新渲染,只有右侧的 {children} 区域会进行极其高效的无刷新替换(局部水合)。这正是现代全栈框架的魅力所在。


三、 页面结构:把业务装进内容区

有了左右结构的骨架,接下来我们在 app/(admin)/users/page.tsx 中创建具体的页面。

记住一个核心原则:页面(Page)本身不应该处理复杂的 API 逻辑,它只负责"组件编排",它是要被塞进上一步 {children} 里的插槽。

tsx 复制代码
// app/(admin)/users/page.tsx
"use client"

import { UserTable } from "@/modules/user/components/user-table"

export default function UserPage() {
  return (
    <div className="space-y-6">
      {/* 页面标题区 */}
      <div>
        <h2 className="text-2xl font-bold tracking-tight">人员管理</h2>
        <p className="text-sm text-gray-500 mt-1">管理系统内的所有员工信息与状态。</p>
      </div>

      {/* 核心业务容器:表格区域 */}
      <UserTable />
    </div>
  )
}

四、 核心容器:UserTable(系统的真正入口)

在任何中后台系统中,最核心的部件往往不是花哨的图表,而是数据表格(Data Table)

初学者经常犯的错误是写一堆 onClick 函数去手动调用 API。而在现代的 React 开发理念中,我们要切换到**"状态驱动"**思维:

tsx 复制代码
// modules/user/components/user-table.tsx
"use client"

import { useEffect, useState } from "react"
import { getUserPage } from "../user.api"
import { UserStatus } from "@/generated/prisma/client"

export function UserTable() {
  // 1. 数据源状态
  const [list, setList] = useState<any[]>([])
  const [loading, setLoading] = useState(false)

  // 2. 分页状态
  const [page, setPage] = useState(1)
  const [pageSize, setPageSize] = useState(10)

  // 3. 筛选条件状态
  const [keyword, setKeyword] = useState("")
  const [status, setStatus] = useState<UserStatus | undefined>()

  // 核心拉取逻辑
  const fetchData = async () => {
    setLoading(true)
    const res = await getUserPage({ page, pageSize, keyword, status })
    setList(res.list)
    setLoading(false)
  }

  // 依赖监听:任何状态变化,自动触发数据刷新
  useEffect(() => {
    fetchData()
  }, [page, pageSize, keyword, status])

  return (
    <div className="bg-white border rounded-xl shadow-sm p-4">
      {/* UI 渲染部分将在这里补充 */}
    </div>
  )
}

💡 关键思维 :我们不需要在每次回车或点击按钮时去手动 fetchData。我们只需要修改 pagekeyword 的状态,React 的 useEffect 就会像低代码的"联动引擎"一样,自动帮你完成数据的重新加载。


五、 引入 shadcn/ui:搭建企业级表格基础设施

在现代前端工程中,从零手写表格既耗时又容易踩坑。我们选择引入当前最火热的组件基建:shadcn/ui

bash 复制代码
npx shadcn@latest add table button input select

不同于传统的组件库(如 Ant Design 的高度封装),shadcn 的 Table 将 TableHeaderTableBodyTableRow 完全暴露给你拼装。这种"积木式"结构赋予了我们极大的定制自由度。


六、 构建与绑定 UI 视图

接下来,我们将 API 返回的 list 状态映射到 shadcn 的组件上,并补齐头部的搜索和底部的分页。

6.1 拼装完整的 UserTable 视图

tsx 复制代码
"use client"

import { useEffect, useState } from "react"
import { RotateCcw } from "lucide-react"

import { getUserPage } from "../user.service"
import { UserStatus } from "@/generated/prisma/client"
import { User } from "@/generated/prisma/client"
import {
  Table,
  TableHeader,
  TableBody,
  TableRow,
  TableHead,
  TableCell,
} from "@/components/ui/table"

import { Input } from "@/components/ui/input"

import {
  Select,
  SelectItem,
  SelectTrigger,
  SelectValue,
  SelectContent,
} from "@/components/ui/select"

import { Button } from "@/components/ui/button"

type StatusValue = UserStatus | "ALL"
type UserItem = User


export function UserTable() {
  /** 数据源状态 */
  const [list, setList] = useState<UserItem[]>([])
  const [loading, setLoading] = useState(false)

  /** 分页状态 */
  const [page, setPage] = useState(1)
  const [pageSize] = useState(10)
  const [total, setTotal] = useState(0)
  const [totalPage, setTotalPage] = useState(1)

  /** 筛选状态 */
  const [keyword, setKeyword] = useState("")
  const [status, setStatus] = useState<StatusValue>("ALL")

  /** 拉取数据 */
  const fetchData = async () => {
    try {
      setLoading(true)

      const res = await getUserPage({
        page,
        pageSize,
        keyword,
        status: status === "ALL" ? undefined : status,
      })

      setList(res.list || [])
      setTotal(res.total || 0)
      setTotalPage(res.totalPage || 1)

      /** 如果当前页超过最大页,自动回退 */
      if (res.totalPage > 0 && page > res.totalPage) {
        setPage(res.totalPage)
      }
    } catch (error) {
      console.error("获取用户列表失败:", error)
      setList([])
      setTotal(0)
      setTotalPage(1)
    } finally {
      setLoading(false)
    }
  }

  /** 重置筛选 */
  const handleReset = () => {
    setKeyword("")
    setStatus("ALL")
    setPage(1)
  }

  /** 状态变化自动刷新 */
  useEffect(() => {
    fetchData()
  }, [page, pageSize, keyword, status])

  /** 状态标签 */
  const StatusBadge = ({ status }: { status: UserStatus }) => {
    const config: Record<
      UserStatus,
      { label: string; className: string }
    > = {
      ACTIVE: {
        label: "在职",
        className: "bg-green-100 text-green-700 border-green-200",
      },
      RESIGNED: {
        label: "离职",
        className: "bg-red-100 text-red-700 border-red-200",
      },
      ON_LEAVE: {
        label: "休假",
        className: "bg-amber-100 text-amber-700 border-amber-200",
      },
    }

    const current = config[status]

    return (
      <span
        className={`px-2 py-1 rounded-full text-xs border font-medium ${current.className}`}
      >
        {current.label}
      </span>
    )
  }

  return (
    <div className="bg-white border rounded-xl shadow-sm">
      {/* 顶部筛选区 */}
      <div className="p-4 flex gap-4 border-b">
        <Input
          className="max-w-xs"
          placeholder="搜索姓名或邮箱"
          value={keyword}
          onChange={(e) => {
            setKeyword(e.target.value)
            setPage(1)
          }}
        />

        <Select
          value={status}
          onValueChange={(v) => {
            setStatus(v as StatusValue)
            setPage(1)
          }}
        >
          <SelectTrigger className="w-[180px]">
            <SelectValue placeholder="所有状态" />
          </SelectTrigger>

          <SelectContent>
            <SelectItem value="ALL">全部状态</SelectItem>
            <SelectItem value="ACTIVE">在职</SelectItem>
            <SelectItem value="RESIGNED">离职</SelectItem>
            <SelectItem value="ON_LEAVE">休假</SelectItem>
          </SelectContent>
        </Select>

        <Button variant="outline" onClick={handleReset}>
          <RotateCcw className="w-4 h-4 mr-2" />
          重置
        </Button>
      </div>

      {/* 表格区 */}
      <Table>
        <TableHeader>
          <TableRow className="bg-gray-50/50">
            <TableHead>姓名</TableHead>
            <TableHead>邮箱</TableHead>
            <TableHead>状态</TableHead>
            <TableHead>创建时间</TableHead>
          </TableRow>
        </TableHeader>

        <TableBody>
          {loading ? (
            <TableRow>
              <TableCell
                colSpan={4}
                className="text-center py-8 text-gray-500"
              >
                加载中...
              </TableCell>
            </TableRow>
          ) : list.length === 0 ? (
            <TableRow>
              <TableCell
                colSpan={4}
                className="text-center py-8 text-gray-500"
              >
                暂无数据
              </TableCell>
            </TableRow>
          ) : (
            list.map((item) => (
              <TableRow key={item.id}>
                <TableCell className="font-medium">
                  {item.name}
                </TableCell>

                <TableCell>{item.email}</TableCell>

                <TableCell>
                  <StatusBadge status={item.status} />
                </TableCell>

                <TableCell className="text-gray-500">
                  {new Date(item.createdAt).toLocaleDateString()}
                </TableCell>
              </TableRow>
            ))
          )}
        </TableBody>
      </Table>

      {/* 分页区 */}
      <div className="p-4 flex items-center justify-between border-t">
        <div className="text-sm text-gray-500">
          共 {total} 条记录
        </div>

        <div className="flex items-center gap-4">
          <Button
            variant="outline"
            size="sm"
            disabled={page === 1 || loading}
            onClick={() =>
              setPage((p) => Math.max(p - 1, 1))
            }
          >
            上一页
          </Button>

          <span className="text-sm text-gray-500 font-medium">
            第 {page} / {totalPage} 页
          </span>

          <Button
            variant="outline"
            size="sm"
            disabled={page >= totalPage || loading}
            onClick={() =>
              setPage((p) =>
                Math.min(p + 1, totalPage)
              )
            }
          >
            下一页
          </Button>
        </div>
      </div>
    </div>
  )
}

这就是所有低代码平台中"表格数据绑定"在底层的真实样貌:一个遍历数组并返回 JSX 节点的闭环。

七、 全景透视:完整的数据流模型

至此,我们的前端容器已经形成了一个完美的闭环。让我们俯瞰一下此时系统内部正在发生什么:

概念定位 低代码平台表现 本项目 Next.js 实现
全局导航 配置菜单结构 layout.tsx (Sidebar + {children})
页面路由 新建列表页 page.tsx
数据引擎 配置并发布数据源 user.api.ts (getUserPage)
视图容器 拖拽 Table 组件 shadcn/ui 的 <Table> 组合
联动引擎 配置事件流(自动刷新) useState + useEffect 依赖数组

最终效果

在浏览器里输入URL,查看结果

bash 复制代码
http://localhost:3000/users

🚀 总结与下一步预告

本节我们完成了一个非常关键的架构跃迁:搭建了左右分栏的后台骨架,并将孤立的 API 升级为了系统的数据源,打通了 UI 的数据双向流通。

在这个过程中,最核心的思维转变在于:

  • 初级思维(事件驱动):点击按钮 → 编写请求逻辑 → 拿到数据手动塞给 UI。
  • ✔️ 成熟思维(状态驱动):定义状态 → 绑定状态变化 → 机制自动触发数据流 → UI 天然保持同步。

目前的表格虽然能看能搜,但它只是"只读"的。在下一章中,我们将进入系统开发更深水区:如何将"新增 / 编辑 / 删除"从零散的页面逻辑,升级为内聚的模块能力? 我们将引入 Next.js 强大的 Server Actions,从"展示数据"正式迈入"操作数据"的新篇章。

相关推荐
zncxCOS7 小时前
【ETestDEV5教程48】UI设计器之UI画布
测试开发·ui·仿真测试·etest·嵌入式系统测试
qcx2315 小时前
Warp源码深度解析(二):自研GPU UI框架——WarpUI的ECH模式与渲染管线
人工智能·ui·设计模式·rust
qq_4523962315 小时前
第十六篇:《如何高效维护UI自动化测试用例:避免“维护地狱”》
ui·自动化·测试用例
低代码布道师16 小时前
注入灵魂:从架构设计到数据能力的“降维打击”
nextjs
十五年专注C++开发16 小时前
CMake基础: Qt之qt5_wrap_ui
开发语言·c++·qt·ui
jf加菲猫16 小时前
第16章 容器类
开发语言·c++·qt·ui
ZC跨境爬虫17 小时前
跟着 MDN 学 HTML day_5:(原生table表格语义化搭建+CSS轻量化交互美化全实战)
前端·css·ui·html
John_ToDebug1 天前
隐于无形,触手可及:Chrome 互动滚动条的六个设计密码
chrome·windows·ui
ZC跨境爬虫2 天前
跟着 MDN 学 HTML day_2:(表单分组与高级输入控件实战)
前端·javascript·css·ui·html