目录
- [一、 页面本质: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 的可视化执行器
如果你熟悉低代码平台,你会发现构建一个列表页面的心智模型非常固化:
- 配置系统菜单(导航)
- 拖拽绑定表格组件到内容区
- 勾选需要显示的筛选条件
- 开启自动分页加载
在 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。我们只需要修改 page 或 keyword 的状态,React 的 useEffect 就会像低代码的"联动引擎"一样,自动帮你完成数据的重新加载。
五、 引入 shadcn/ui:搭建企业级表格基础设施
在现代前端工程中,从零手写表格既耗时又容易踩坑。我们选择引入当前最火热的组件基建:shadcn/ui。
bash
npx shadcn@latest add table button input select
不同于传统的组件库(如 Ant Design 的高度封装),shadcn 的 Table 将 TableHeader、TableBody、TableRow 完全暴露给你拼装。这种"积木式"结构赋予了我们极大的定制自由度。
六、 构建与绑定 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,从"展示数据"正式迈入"操作数据"的新篇章。