Next.js 16 全栈实战(三):数据库建模与动态菜单实现

目录

  • 前言
  • [1. 设计"大脑":定义 TypeScript 类型](#1. 设计“大脑”:定义 TypeScript 类型)
  • [2. 注入灵魂:编写数据库种子脚本 (Seed)](#2. 注入灵魂:编写数据库种子脚本 (Seed))
    • [2.1 准备假数据](#2.1 准备假数据)
    • [2.2 编写执行脚本](#2.2 编写执行脚本)
    • [2.3 执行初始化](#2.3 执行初始化)
  • [3. 核心:Server Components 数据获取](#3. 核心:Server Components 数据获取)
  • [4. 联调:让 Sidebar 动起来](#4. 联调:让 Sidebar 动起来)
  • [5. 验证成果](#5. 验证成果)
  • 下一步预告

前言

前情回顾:

在第二篇中,我们引入了 Shadcn UI,并搭建了 Dashboard 的静态布局。

本篇目标:

  1. 数据建模:设计支持 RBAC(基于角色的权限控制)的数据库表结构。
  2. 数据填充:编写 Seed 脚本,一键初始化数据库。
  3. 服务端数据获取:利用 Next.js Server Components 的特性,直接从数据库读取菜单,让侧边栏"活"起来。

1. 设计"大脑":定义 TypeScript 类型

在全栈开发中,类型安全是我们的护城河。我们先定义数据长什么样。

app/lib 目录下,新建文件 definitions.ts。我们将定义核心的"菜单"和"角色"类型。

typescript 复制代码
// app/lib/definitions.ts

// 菜单类型定义
export type Menu = {
  menu_id: string;      // UUID
  parent_id: string;    // 父菜单ID
  menu_name: string;    // 菜单显示名称
  menu_type: 'M' | 'C' | 'F'; // M:目录, C:菜单, F:按钮
  path: string;         // 路由路径
  perms: string;        // 权限标识 (如 system:user:list)
  icon?: string;        // 图标字符串
};

// 角色类型定义
export type Role = {
  role_id: string;
  role_name: string;
  role_key: string;     // 如 admin, editor
  data_scope: string;   // 数据权限范围
};

AI 编程技巧:

你可以直接把这两个类型复制给 Trae 的 AI 助手,告诉它:"记住这些类型定义,接下来的数据库查询代码都要符合这个结构。"


2. 注入灵魂:编写数据库种子脚本 (Seed)

数据库现在是空的,我们需要一些初始数据(比如"系统管理"菜单、"超级管理员"用户)。

2.1 准备假数据

新建 app/lib/placeholder-data.ts。这里我们模拟一些侧边栏菜单数据:

typescript 复制代码
// app/lib/placeholder-data.ts

// 定义一些初始菜单
export const menus = [
  { id: '550e8400-e29b-41d4-a716-446655440001', parent_id: '0', name: '工作台', type: 'C', path: '/dashboard', perms: '', icon: 'LayoutDashboard' },
  { id: '550e8400-e29b-41d4-a716-446655440002', parent_id: '0', name: '系统管理', type: 'M', path: '/system', perms: '', icon: 'Settings' },
  { id: '550e8400-e29b-41d4-a716-446655440003', parent_id: '550e8400-e29b-41d4-a716-446655440002', name: '人员管理', type: 'C', path: '/dashboard/user', perms: 'system:user:list', icon: 'Users' },
  { id: '550e8400-e29b-41d4-a716-446655440004', parent_id: '550e8400-e29b-41d4-a716-446655440002', name: '角色管理', type: 'C', path: '/dashboard/role', perms: 'system:role:list', icon: 'ShieldCheck' },
];

2.2 编写执行脚本

为了方便初学者,我们使用 Next.js 的 Route Handler 来触发数据库初始化。

新建 app/seed/route.ts

typescript 复制代码
// @ts-ignore
import postgres from 'postgres';
import { menus } from '../lib/placeholder-data';

const sql = postgres(process.env.POSTGRES_URL!, { ssl: false });

async function seedMenus() {
  // 1. 建表
  await sql`
    CREATE TABLE IF NOT EXISTS sys_menu (
      menu_id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
      parent_id VARCHAR(255) NOT NULL,
      menu_name VARCHAR(255) NOT NULL,
      menu_type CHAR(1) NOT NULL,
      path VARCHAR(255),
      perms VARCHAR(100),
      icon VARCHAR(50)
    );
  `;

  // 2. 插入数据
  // 注意:真实项目中 parent_id 也应该是 UUID,这里为了演示简化处理
  const insertedMenus = await Promise.all(
    menus.map((menu) => sql`
      INSERT INTO sys_menu (menu_id, parent_id, menu_name, menu_type, path, perms, icon)
      VALUES (${menu.id}, ${menu.parent_id}, ${menu.name}, ${menu.type}, ${menu.path}, ${menu.perms}, ${menu.icon})
      ON CONFLICT (menu_id) DO NOTHING;
    `)
  );

  return insertedMenus;
}

export async function GET() {
  try {
    await sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`; // 启用 UUID 插件
    await sql.begin(async (sql: postgres.TransactionSql) => {
      await seedMenus();
    });
    return Response.json({ message: 'Database seeded successfully' });
  } catch (error) {
    return Response.json({ error }, { status: 500 });
  }
}

2.3 执行初始化

  1. 确保你的 .env 文件里配置了正确的 POSTGRES_URL
  2. 启动项目:npm run dev
  3. 在浏览器访问:http://localhost:3000/seed
  4. 如果你看到 {"message": "Database seeded successfully"},恭喜你,数据库已经就绪!

运行后,提示报错,我们可以将错误贴给kimi让大模型修复一下

安装好依赖的包后再次运行我们的地址就可以看到表创建成功了


3. 核心:Server Components 数据获取

这是 Next.js 最强大的地方:我们不需要写 API 接口(/api/menus),也不需要前端 useEffect 去 fetch。组件直接连数据库!

新建 app/lib/data.ts,专门用于存放 SQL 查询逻辑:

typescript 复制代码
import postgres from 'postgres';
import { Menu } from './definitions';

const sql = postgres(process.env.POSTGRES_URL!, { ssl: false });

export async function fetchMenus() {
  try {
    // 模拟延时,让你感受 loading 状态
    // await new Promise((resolve) => setTimeout(resolve, 3000));
    console.log('Fetching menus...');
    // 查询所有目录(M)和菜单(C),排除按钮(F)
    const data = await sql<Menu[]>`
      SELECT * FROM sys_menu 
      WHERE menu_type IN ('M', 'C') 
      ORDER BY menu_id ASC
    `;
    return data;
  } catch (error) {
    console.error('Database Error:', error);
    throw new Error('Failed to fetch menus.');
  }
}

回到 app/ui/dashboard/sidenav.tsx。我们需要做两件事:

  1. 调用 fetchMenus 获取数据。
  2. 把数据库里的图标字符串(如 "Users")映射回 React 组件。

修改代码如下:

tsx 复制代码
import Link from 'next/link';
// 1. 引入图标映射工具
import * as Icons from 'lucide-react';
import { fetchMenus } from '@/app/lib/data';
import { GraduationCap, LogOut } from 'lucide-react';

export default async function SideNav() {
  // 2. 直接在组件中获取数据 (Server Component 的魔法)
  const menuItems = await fetchMenus();

  return (
    <div className="flex h-full flex-col px-3 py-4 md:px-2">
      <Link className="..." href="/">
        {/* Logo 部分保持不变 */}
        <div className="w-32 text-white md:w-40 flex items-center gap-2">
           <GraduationCap className="h-8 w-8" />
           <span className="text-lg font-bold">教培管家</span>
        </div>
      </Link>

      <div className="flex grow flex-row justify-between space-x-2 md:flex-col md:space-x-0 md:space-y-2">
        {/* 3. 动态渲染菜单 */}
        {menuItems.map((item) => {
          // 动态获取图标组件,如果没有则使用默认图标
          // @ts-ignore
          const IconComponent = Icons[item.icon] || Icons.Circle;
          
          return (
            <Link
              key={item.menu_id}
              href={item.path}
              className="flex h-[48px] grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3"
            >
              <IconComponent className="w-6" />
              <p className="hidden md:block">{item.menu_name}</p>
            </Link>
          );
        })}

        <div className="hidden h-auto w-full grow rounded-md bg-gray-50 md:block"></div>
        
        <form>
          <button className="flex h-[48px] w-full grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3">
            <LogOut className="w-6" />
            <div className="hidden md:block">退出登录</div>
          </button>
        </form>
      </div>
    </div>
  );
}

5. 验证成果

保存所有文件,刷新 http://localhost:3000/dashboard

你会发现侧边栏的内容已经不再是硬编码的了!我们使用自带的pgAdmin工具,修改一下菜单的名称,刷新页面,变化会立刻反映出来。

你刚刚完成了一个重要的里程碑:

  1. 你建立了一个真实的数据库连接。
  2. 你理解了 Next.js 如何在服务端"直接"拿数据。
  3. 你实现了前端 UI 与后端数据的打通。

下一步预告

现在的页面跳转虽然快,但如果在网速慢的时候,用户点击菜单可能会感觉"卡顿"(因为正在等待数据库查询)。

下一篇 中,我们将优化用户体验:

  • 使用 Next.js 的 Streaming (流式渲染)Suspense
  • 制作一个漂亮的 Skeleton (骨架屏),在数据加载完成前展示占位动画。
  • 顺便,我们将实现 URL 路径高亮(Active State),让用户知道自己当前在哪里。

关注我,一起构建更丝滑的全栈应用!

相关推荐
MediaTea1 小时前
Python:生成器对象的扩展接口
开发语言·网络·python
前路不黑暗@1 小时前
Java项目:Java脚手架项目的模板服务和网关服务的实现(三)
java·开发语言·spring boot·git·学习·spring cloud·maven
远方16091 小时前
114-Oracle Database 26ai在Oracle Linux 9上的OUI图形界面安装
linux·服务器·数据库·sql·oracle·database
heimeiyingwang2 小时前
向量数据库Milvus的安装部署指南
java·数据库·架构·database
山岚的运维笔记2 小时前
SQL Server笔记 -- 第50章 存储过程
数据库·笔记·sql·microsoft·oracle·sqlserver
白太岁2 小时前
操作系统开发:(8) 任务/线程的创建、调度与管理(实现 tasks.h 与 tasks.c)
c语言·开发语言·bash
Zachery Pole2 小时前
JAVA_06_方法
java·开发语言
LSL666_2 小时前
10 集群
java·开发语言·数据库·redis·集群
好家伙VCC2 小时前
# 发散创新:基于Python的轻量级测试框架设计与实践 在现代软件开发中,**自动化
java·开发语言·python·自动化