Next.js 结合 MySQL 数据库全站开发教程

Next.js 结合 MySQL 数据库全站开发教程

前言

随着现代 Web 应用的发展,前后端分离架构已成为主流。Next.js 作为一款强大的 React 框架,不仅提供了出色的前端开发体验,还内置了 API 路由功能,使得在同一项目中开发前后端成为可能。结合 MySQL 这样成熟的关系型数据库,我们可以构建功能完善、性能优异的全栈应用。

本教程将带您一步步学习如何使用 Next.js 和 MySQL 构建全栈应用,内容涵盖项目初始化、数据库配置、实体设计、服务层实现、API 路由开发以及前端页面展示等方面。通过本教程,您将掌握使用 Next.js 和 MySQL 进行全栈开发的完整流程和最佳实践。

1. 环境准备

1.1 安装 Node.js 和 npm/yarn

确保您已安装 Node.js (推荐 v18 或更高版本):

bash 复制代码
node -v

1.2 安装 MySQL

您可以通过以下方式安装 MySQL:

bash 复制代码
docker run --name mysql -e MYSQL_ROOT_PASSWORD=password -p 3306:3306 -d mysql:8

1.3 创建 Next.js 项目

使用 create-next-app 创建新项目:

bash 复制代码
npx create-next-app@latest my-nextjs-mysql-app
cd my-nextjs-mysql-app

按照提示选择项目配置,推荐启用 TypeScript、ESLint、Tailwind CSS 等功能。

1.4 安装必要依赖

bash 复制代码
npm install mysql2 typeorm reflect-metadata class-validator
# 或使用 yarn
yarn add mysql2 typeorm reflect-metadata class-validator

这些依赖的作用:

  • mysql2:MySQL 数据库驱动
  • typeorm:强大的 ORM 框架,提供了实体映射、查询构建等功能
  • reflect-metadata:为装饰器提供元数据反射功能
  • class-validator:用于验证类和对象属性

1.5 配置 TypeScript

tsconfig.json 中添加以下配置,以支持装饰器和元数据反射:

json 复制代码
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
    // 其他配置保持不变
  }
}

2. 项目结构设计

一个良好的项目结构可以提高代码的可维护性和可扩展性。以下是本教程推荐的项目结构:

csharp 复制代码
my-nextjs-mysql-app/
├── src/
│   ├── app/                # Next.js App Router 目录
│   │   ├── api/            # API 路由
│   │   └── ...             # 页面组件
│   ├── components/         # UI 组件
│   ├── config/             # 配置文件
│   │   └── database.ts     # 数据库配置
│   ├── entities/           # TypeORM 实体定义
│   ├── services/           # 业务逻辑层
│   └── utils/              # 工具函数
├── public/                 # 静态资源
└── ...                     # 其他配置文件

3. 数据库配置

3.1 创建数据库连接配置

创建 src/config/database.ts 文件,配置 TypeORM 数据源:

typescript 复制代码
import "reflect-metadata";
import { DataSource } from "typeorm";
import { User } from "../entities/user.entity";
import { Products } from "../entities/product.entity";

// 声明全局变量以在开发环境热重载中保持数据库连接
declare global {
  var dbConnection: {
    dataSource: DataSource | undefined;
    promise: Promise<DataSource> | null;
  };
}

// 初始化全局变量
if (!global.dbConnection) {
  global.dbConnection = {
    dataSource: undefined,
    promise: null
  };
}

// 创建数据库连接实例
export const AppDataSource = new DataSource({
  type: "mysql",
  host: process.env.DB_HOST || "localhost",
  port: parseInt(process.env.DB_PORT || "3306"),
  username: process.env.DB_USER || "root",
  password: process.env.DB_PASSWORD || "password",
  database: process.env.DB_NAME || "nextjs_db",
  synchronize: process.env.NODE_ENV !== "production", // 开发环境自动同步数据库结构
  logging: process.env.NODE_ENV !== "production",
  entities: [User, Products], // 注册所有实体
  migrations: [],
  subscribers: [],
  connectTimeout: 20000
});

// 初始化数据库连接
export const initDatabase = async (): Promise<DataSource> => {
  // 如果已有数据源实例且已初始化,则直接返回
  if (
    global.dbConnection.dataSource &&
    global.dbConnection.dataSource.isInitialized
  ) {
    return global.dbConnection.dataSource;
  }

  // 如果已经有初始化中的Promise,则等待它完成
  if (global.dbConnection.promise) {
    return global.dbConnection.promise;
  }

  // 创建新的初始化Promise
  global.dbConnection.promise = (async () => {
    try {
      if (!AppDataSource.isInitialized) {
        await AppDataSource.initialize();
        console.log("数据库连接成功");
      }

      global.dbConnection.dataSource = AppDataSource;
      return AppDataSource;
    } catch (error) {
      console.error("数据库连接失败:", error);
      global.dbConnection.promise = null;
      throw error;
    }
  })();

  return global.dbConnection.promise;
};

3.2 创建数据库初始化工具

为了方便在服务中使用数据库连接,创建 src/utils/databaseUtils.ts 文件:

typescript 复制代码
import { initDatabase } from "../config/database";

// 确保数据库已初始化
export const ensureInitialized = async () => {
  try {
    await initDatabase();
  } catch (error) {
    console.error("数据库初始化失败:", error);
    throw error;
  }
};

3.3 环境变量配置

创建 .env.local 文件,设置数据库连接参数:

ini 复制代码
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=password
DB_NAME=nextjs_db

注意:在生产环境中,请确保这些敏感信息的安全性。

4. 实体(Entity)设计

TypeORM 使用实体来映射数据库表。实体是一个简单的类,使用装饰器来定义其属性和关系。

4.1 创建用户实体

创建 src/entities/user.entity.ts 文件:

typescript 复制代码
import { Entity, Column, PrimaryGeneratedColumn } from "typeorm";

@Entity("user")
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ nullable: true })
  name: string;

  @Column({ nullable: true })
  email: string;

  @Column({ unique: true })
  account: string;

  @Column()
  password: string;
}

4.2 创建产品实体

创建 src/entities/product.entity.ts 文件:

typescript 复制代码
import { Entity, Column, PrimaryGeneratedColumn } from "typeorm";

@Entity("products")
export class Products {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @Column()
  imagePath: string;

  @Column()
  type: number;

  @Column({ type: "float", default: 0 })
  carbonBlack: number;

  @Column({ type: "float", default: 0 })
  ashContent: number;

  @Column({ type: "float", default: 0 })
  dispersibility: number;

  @Column({ default: false })
  hot: boolean;
}

5. 服务层(Service)开发

服务层负责处理业务逻辑,是连接数据访问层和控制器层的桥梁。

5.1 创建产品服务

创建 src/services/productService.ts 文件:

typescript 复制代码
import { AppDataSource } from "../config/database";
import { Products } from "../entities/product.entity";
import { Like, Repository } from "typeorm";
import { validate } from "class-validator";
import { ensureInitialized } from "../utils/databaseUtils";

// 定义查询过滤参数接口
export interface ProductFilter {
  page?: number;
  pageSize?: number;
  name?: string;
  type?: string;
  isHot?: boolean;
}

class ProductService {
  // 获取仓库实例
  private async getRepository(): Promise<Repository<Products>> {
    // 确保数据库连接已初始化
    await ensureInitialized();
    return AppDataSource.getRepository(Products);
  }

  // 获取产品列表,支持分页和过滤
  async getProducts(filter: ProductFilter = {}): Promise<{
    data: Products[];
    total: number;
    page: number;
    pageSize: number;
  }> {
    try {
      const productRepository = await this.getRepository();

      const { page = 1, pageSize = 10, name, type, isHot } = filter;

      // 构建查询条件
      const where: any = {};
      if (name) {
        where.name = Like(`%${name}%`); // 模糊搜索
      }
      if (type) {
        where.type = type;
      }
      if (isHot !== undefined) {
        where.hot = isHot;
      }

      // 执行查询,获取分页数据
      const [data, total] = await productRepository.findAndCount({
        where,
        skip: (page - 1) * pageSize,
        take: pageSize
      });

      return {
        data,
        total,
        page,
        pageSize
      };
    } catch (error) {
      console.error("获取产品列表失败:", error);
      throw error;
    }
  }

  // 根据ID获取单个产品
  async getProductById(id: number): Promise<Products | null> {
    try {
      const productRepository = await this.getRepository();
      return await productRepository.findOneBy({ id });
    } catch (error) {
      console.error(`获取产品 ID ${id} 失败:`, error);
      throw error;
    }
  }

  // 创建产品
  async createProduct(productData: Partial<Products>): Promise<Products> {
    try {
      const productRepository = await this.getRepository();
      const product = productRepository.create(productData);

      // 使用class-validator验证实体
      const errors = await validate(product);
      if (errors.length > 0) {
        throw new Error(
          `验证错误: ${errors.map(error => Object.values(error.constraints || {}).join(", ")).join("; ")}`
        );
      }

      return await productRepository.save(product);
    } catch (error) {
      console.error("创建产品失败:", error);
      throw error;
    }
  }

  // 更新产品
  async updateProduct(
    id: number,
    productData: Partial<Products>
  ): Promise<Products | null> {
    try {
      const productRepository = await this.getRepository();
      const product = await productRepository.findOneBy({ id });

      if (!product) {
        return null;
      }

      // 合并更新数据
      Object.assign(product, productData);

      // 验证更新后的数据
      const errors = await validate(product);
      if (errors.length > 0) {
        throw new Error(
          `验证错误: ${errors.map(error => Object.values(error.constraints || {}).join(", ")).join("; ")}`
        );
      }

      return await productRepository.save(product);
    } catch (error) {
      console.error(`更新产品 ID ${id} 失败:`, error);
      throw error;
    }
  }

  // 删除产品
  async deleteProduct(id: number): Promise<boolean> {
    try {
      const productRepository = await this.getRepository();
      const result = await productRepository.delete(id);
      return result.affected ? result.affected > 0 : false;
    } catch (error) {
      console.error(`删除产品 ID ${id} 失败:`, error);
      throw error;
    }
  }
}

// 导出单例实例
export const productService = new ProductService();
export default productService;

5.2 创建用户服务

创建 src/services/userService.ts 文件:

typescript 复制代码
import { AppDataSource } from "../config/database";
import { User } from "../entities/user.entity";
import { Repository } from "typeorm";
import { validate } from "class-validator";
import { ensureInitialized } from "../utils/databaseUtils";

class UserService {
  // 获取仓库实例
  private async getRepository(): Promise<Repository<User>> {
    await ensureInitialized();
    return AppDataSource.getRepository(User);
  }

  // 根据账号查找用户
  async findByAccount(account: string): Promise<User | null> {
    try {
      const userRepository = await this.getRepository();
      return await userRepository.findOneBy({ account });
    } catch (error) {
      console.error(`查找用户 ${account} 失败:`, error);
      throw error;
    }
  }

  // 创建新用户
  async createUser(userData: Partial<User>): Promise<User> {
    try {
      const userRepository = await this.getRepository();
      const user = userRepository.create(userData);

      // 验证用户数据
      const errors = await validate(user);
      if (errors.length > 0) {
        throw new Error(
          `验证错误: ${errors.map(error => Object.values(error.constraints || {}).join(", ")).join("; ")}`
        );
      }

      return await userRepository.save(user);
    } catch (error) {
      console.error("创建用户失败:", error);
      throw error;
    }
  }
}

// 导出单例实例
export const userService = new UserService();
export default userService;

6. API 路由开发

Next.js 的 App Router 为我们提供了强大的 API 路由功能,可以直接在同一项目中开发后端 API。

6.1 创建 API 响应工具

首先,创建一个工具函数来统一 API 响应格式。创建 src/utils/apiResponse.ts 文件:

typescript 复制代码
// 定义错误码枚举
export enum ErrorCode {
  VALIDATION_ERROR = "VALIDATION_ERROR",
  NOT_FOUND = "NOT_FOUND",
  UNAUTHORIZED = "UNAUTHORIZED",
  FORBIDDEN = "FORBIDDEN",
  CONFLICT = "CONFLICT",
  QUERY_ERROR = "QUERY_ERROR",
  SERVER_ERROR = "SERVER_ERROR"
}

// 成功响应
export const successResponse = (data: any, message = "操作成功", meta = {}) => {
  return Response.json({
    success: true,
    message,
    data,
    meta
  });
};

// 错误响应
export const errorResponse = (
  message: string,
  error: string,
  status = 500,
  code = ErrorCode.SERVER_ERROR
) => {
  return Response.json(
    {
      success: false,
      message,
      error,
      code
    },
    { status }
  );
};

6.2 创建产品列表 API

创建 src/app/api/products/list/route.ts 文件:

typescript 复制代码
import { NextRequest } from "next/server";
import { successResponse, errorResponse, ErrorCode } from "@/utils/apiResponse";
import productService from "@/services/productService";

/**
 * 获取产品列表
 * GET /api/products/list
 */
export async function GET(request: NextRequest) {
  try {
    // 从URL中获取查询参数
    const searchParams = request.nextUrl.searchParams;
    const {
      name,
      type,
      page = "1",
      pageSize = "10",
      isHot
    } = Object.fromEntries(searchParams.entries());

    // 调用服务获取产品列表
    const result = await productService.getProducts({
      name,
      type,
      page: Number(page),
      pageSize: Number(pageSize),
      isHot: isHot === "true"
    });

    // 返回成功响应
    return successResponse(result.data, "获取产品列表成功", {
      total: result.total,
      page: result.page,
      pageSize: result.pageSize,
      totalPages: Math.ceil(result.total / result.pageSize)
    });
  } catch (error) {
    // 记录错误
    console.error("获取产品列表失败:", error);

    // 返回错误响应
    return errorResponse(
      "获取产品列表失败",
      error instanceof Error ? error.message : "未知错误",
      500,
      ErrorCode.QUERY_ERROR
    );
  }
}

6.3 创建产品详情 API

创建 src/app/api/products/[id]/route.ts 文件:

typescript 复制代码
import { NextRequest } from "next/server";
import { successResponse, errorResponse, ErrorCode } from "@/utils/apiResponse";
import productService from "@/services/productService";

/**
 * 获取产品详情
 * GET /api/products/[id]
 */
export async function GET(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const id = parseInt(params.id);

    if (isNaN(id)) {
      return errorResponse(
        "无效的产品ID",
        "产品ID必须是数字",
        400,
        ErrorCode.VALIDATION_ERROR
      );
    }

    const product = await productService.getProductById(id);

    if (!product) {
      return errorResponse(
        "产品不存在",
        `找不到ID为 ${id} 的产品`,
        404,
        ErrorCode.NOT_FOUND
      );
    }

    return successResponse(product, "获取产品详情成功");
  } catch (error) {
    console.error(`获取产品详情失败:`, error);

    return errorResponse(
      "获取产品详情失败",
      error instanceof Error ? error.message : "未知错误",
      500,
      ErrorCode.QUERY_ERROR
    );
  }
}

/**
 * 更新产品
 * PUT /api/products/[id]
 */
export async function PUT(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const id = parseInt(params.id);

    if (isNaN(id)) {
      return errorResponse(
        "无效的产品ID",
        "产品ID必须是数字",
        400,
        ErrorCode.VALIDATION_ERROR
      );
    }

    // 获取请求体
    const productData = await request.json();

    // 更新产品
    const updatedProduct = await productService.updateProduct(id, productData);

    if (!updatedProduct) {
      return errorResponse(
        "产品不存在",
        `找不到ID为 ${id} 的产品`,
        404,
        ErrorCode.NOT_FOUND
      );
    }

    return successResponse(updatedProduct, "更新产品成功");
  } catch (error) {
    console.error(`更新产品失败:`, error);

    return errorResponse(
      "更新产品失败",
      error instanceof Error ? error.message : "未知错误",
      500,
      ErrorCode.SERVER_ERROR
    );
  }
}

/**
 * 删除产品
 * DELETE /api/products/[id]
 */
export async function DELETE(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const id = parseInt(params.id);

    if (isNaN(id)) {
      return errorResponse(
        "无效的产品ID",
        "产品ID必须是数字",
        400,
        ErrorCode.VALIDATION_ERROR
      );
    }

    // 删除产品
    const success = await productService.deleteProduct(id);

    if (!success) {
      return errorResponse(
        "产品不存在",
        `找不到ID为 ${id} 的产品`,
        404,
        ErrorCode.NOT_FOUND
      );
    }

    return successResponse(null, "删除产品成功");
  } catch (error) {
    console.error(`删除产品失败:`, error);

    return errorResponse(
      "删除产品失败",
      error instanceof Error ? error.message : "未知错误",
      500,
      ErrorCode.SERVER_ERROR
    );
  }
}

6.4 创建创建产品 API

创建 src/app/api/products/route.ts 文件:

typescript 复制代码
import { NextRequest } from "next/server";
import { successResponse, errorResponse, ErrorCode } from "@/utils/apiResponse";
import productService from "@/services/productService";

/**
 * 创建产品
 * POST /api/products
 */
export async function POST(request: NextRequest) {
  try {
    // 获取请求体
    const productData = await request.json();

    // 创建新产品
    const newProduct = await productService.createProduct(productData);

    return successResponse(newProduct, "创建产品成功", {}, 201);
  } catch (error) {
    console.error("创建产品失败:", error);

    // 判断是否是验证错误
    const errorMessage = error instanceof Error ? error.message : "未知错误";
    const isValidationError = errorMessage.includes("验证错误");

    return errorResponse(
      "创建产品失败",
      errorMessage,
      isValidationError ? 400 : 500,
      isValidationError ? ErrorCode.VALIDATION_ERROR : ErrorCode.SERVER_ERROR
    );
  }
}

7. 前端页面开发

有了后端 API,现在我们可以开发前端页面来展示和操作数据。

7.1 创建数据获取工具

首先,创建一个用于前端页面数据获取的工具函数。创建 src/services/api_products.ts 文件:

typescript 复制代码
// 产品相关API
import axios from "axios";

// 产品列表查询参数接口
export interface ProductQuery {
  page?: number;
  pageSize?: number;
  name?: string;
  type?: string;
  isHot?: boolean;
}

// 产品数据接口
export interface Product {
  id: number;
  name: string;
  imagePath: string;
  type: number;
  carbonBlack: number;
  ashContent: number;
  dispersibility: number;
  hot: boolean;
}

// 获取产品列表
export async function getProducts(query: ProductQuery = {}) {
  try {
    // 构建查询参数
    const params = new URLSearchParams();

    if (query.page) params.append("page", query.page.toString());
    if (query.pageSize) params.append("pageSize", query.pageSize.toString());
    if (query.name) params.append("name", query.name);
    if (query.type) params.append("type", query.type);
    if (query.isHot !== undefined)
      params.append("isHot", query.isHot.toString());

    // 发送请求
    const response = await axios.get(`/api/products/list?${params.toString()}`);
    return response.data;
  } catch (error) {
    console.error("获取产品列表失败:", error);
    throw error;
  }
}

// 获取产品详情
export async function getProductById(id: number) {
  try {
    const response = await axios.get(`/api/products/${id}`);
    return response.data;
  } catch (error) {
    console.error(`获取产品 ID ${id} 详情失败:`, error);
    throw error;
  }
}

// 创建产品
export async function createProduct(data: Omit<Product, "id">) {
  try {
    const response = await axios.post("/api/products", data);
    return response.data;
  } catch (error) {
    console.error("创建产品失败:", error);
    throw error;
  }
}

// 更新产品
export async function updateProduct(id: number, data: Partial<Product>) {
  try {
    const response = await axios.put(`/api/products/${id}`, data);
    return response.data;
  } catch (error) {
    console.error(`更新产品 ID ${id} 失败:`, error);
    throw error;
  }
}

// 删除产品
export async function deleteProduct(id: number) {
  try {
    const response = await axios.delete(`/api/products/${id}`);
    return response.data;
  } catch (error) {
    console.error(`删除产品 ID ${id} 失败:`, error);
    throw error;
  }
}

7.2 创建产品列表页面

创建 src/app/products/page.tsx 文件:

tsx 复制代码
"use client";

import { useState, useEffect } from "react";
import { getProducts, Product } from "@/services/api_products";
import Link from "next/link";

// 分页数据结构
interface PageData {
  total: number;
  page: number;
  pageSize: number;
  totalPages: number;
}

export default function ProductsPage() {
  const [products, setProducts] = useState<Product[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [pageData, setPageData] = useState<PageData>({
    total: 0,
    page: 1,
    pageSize: 10,
    totalPages: 0
  });

  // 查询参数
  const [searchName, setSearchName] = useState("");
  const [selectedType, setSelectedType] = useState("");

  // 加载产品数据
  const loadProducts = async (
    page = 1,
    name = searchName,
    type = selectedType
  ) => {
    try {
      setLoading(true);

      const response = await getProducts({
        page,
        pageSize: pageData.pageSize,
        name: name || undefined,
        type: type || undefined
      });

      if (response.success) {
        setProducts(response.data);
        setPageData({
          total: response.meta.total,
          page: response.meta.page,
          pageSize: response.meta.limit,
          totalPages: response.meta.totalPages
        });
        setError(null);
      } else {
        setError(response.message || "获取产品列表失败");
      }
    } catch (err) {
      setError("获取产品列表时发生错误");
      console.error(err);
    } finally {
      setLoading(false);
    }
  };

  // 首次加载
  useEffect(() => {
    loadProducts();
  }, []);

  // 处理搜索
  const handleSearch = (e: React.FormEvent) => {
    e.preventDefault();
    loadProducts(1, searchName, selectedType);
  };

  // 处理页码变化
  const handlePageChange = (page: number) => {
    loadProducts(page, searchName, selectedType);
  };

  // 产品类型列表
  const productTypes = [
    { value: "1", label: "类型一" },
    { value: "2", label: "类型二" },
    { value: "3", label: "类型三" }
  ];

  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-6">产品列表</h1>

      {/* 搜索表单 */}
      <form onSubmit={handleSearch} className="mb-6 p-4 bg-gray-50 rounded-lg">
        <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
          <div>
            <label htmlFor="name" className="block mb-1">
              产品名称
            </label>
            <input
              type="text"
              id="name"
              value={searchName}
              onChange={e => setSearchName(e.target.value)}
              className="w-full p-2 border rounded"
              placeholder="搜索产品名称"
            />
          </div>

          <div>
            <label htmlFor="type" className="block mb-1">
              产品类型
            </label>
            <select
              id="type"
              value={selectedType}
              onChange={e => setSelectedType(e.target.value)}
              className="w-full p-2 border rounded"
            >
              <option value="">全部类型</option>
              {productTypes.map(type => (
                <option key={type.value} value={type.value}>
                  {type.label}
                </option>
              ))}
            </select>
          </div>

          <div className="flex items-end">
            <button
              type="submit"
              className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
            >
              搜索
            </button>
          </div>
        </div>
      </form>

      {/* 错误提示 */}
      {error && (
        <div className="mb-4 p-3 bg-red-100 text-red-700 rounded">{error}</div>
      )}

      {/* 加载状态 */}
      {loading ? (
        <div className="text-center py-8">加载中...</div>
      ) : (
        <>
          {/* 产品列表 */}
          <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
            {products.map(product => (
              <div
                key={product.id}
                className="border rounded-lg overflow-hidden shadow-sm hover:shadow-md transition-shadow"
              >
                <div className="h-48 bg-gray-200 relative">
                  {product.imagePath ? (
                    <img
                      src={product.imagePath}
                      alt={product.name}
                      className="w-full h-full object-cover"
                    />
                  ) : (
                    <div className="flex items-center justify-center h-full text-gray-500">
                      暂无图片
                    </div>
                  )}
                  {product.hot && (
                    <span className="absolute top-2 right-2 bg-red-500 text-white text-xs px-2 py-1 rounded">
                      热门
                    </span>
                  )}
                </div>

                <div className="p-4">
                  <h3 className="text-lg font-semibold mb-2">{product.name}</h3>
                  <div className="text-sm text-gray-600 mb-4">
                    <p>
                      类型:{" "}
                      {productTypes.find(
                        t => t.value === product.type.toString()
                      )?.label || "未知"}
                    </p>
                    <p>炭黑含量: {product.carbonBlack}</p>
                    <p>灰分: {product.ashContent}</p>
                    <p>分散性: {product.dispersibility}</p>
                  </div>

                  <Link
                    href={`/products/${product.id}`}
                    className="inline-block px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
                  >
                    查看详情
                  </Link>
                </div>
              </div>
            ))}
          </div>

          {/* 分页控件 */}
          {products.length > 0 ? (
            <div className="flex justify-center mt-8">
              <nav className="inline-flex rounded-md shadow-sm">
                <button
                  onClick={() => handlePageChange(pageData.page - 1)}
                  disabled={pageData.page === 1}
                  className={`px-3 py-1 rounded-l-md border ${
                    pageData.page === 1
                      ? "bg-gray-100 text-gray-400 cursor-not-allowed"
                      : "bg-white text-blue-500 hover:bg-blue-50"
                  }`}
                >
                  上一页
                </button>

                {Array.from(
                  { length: pageData.totalPages },
                  (_, i) => i + 1
                ).map(page => (
                  <button
                    key={page}
                    onClick={() => handlePageChange(page)}
                    className={`px-3 py-1 border-t border-b ${
                      pageData.page === page
                        ? "bg-blue-500 text-white"
                        : "bg-white text-blue-500 hover:bg-blue-50"
                    }`}
                  >
                    {page}
                  </button>
                ))}

                <button
                  onClick={() => handlePageChange(pageData.page + 1)}
                  disabled={pageData.page === pageData.totalPages}
                  className={`px-3 py-1 rounded-r-md border ${
                    pageData.page === pageData.totalPages
                      ? "bg-gray-100 text-gray-400 cursor-not-allowed"
                      : "bg-white text-blue-500 hover:bg-blue-50"
                  }`}
                >
                  下一页
                </button>
              </nav>
            </div>
          ) : (
            <div className="text-center py-8 text-gray-500">
              没有找到符合条件的产品
            </div>
          )}
        </>
      )}
    </div>
  );
}

7.3 创建产品详情页面

创建 src/app/products/[id]/page.tsx 文件:

tsx 复制代码
"use client";

import { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import { getProductById, Product } from "@/services/api_products";

export default function ProductDetailPage() {
  const params = useParams();
  const router = useRouter();
  const [product, setProduct] = useState<Product | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  // 产品类型映射
  const productTypes = {
    1: "类型一",
    2: "类型二",
    3: "类型三"
  };

  useEffect(() => {
    // 获取产品ID
    const productId = params?.id ? parseInt(params.id as string) : 0;

    if (!productId) {
      setError("无效的产品ID");
      setLoading(false);
      return;
    }

    // 加载产品详情
    const loadProductDetail = async () => {
      try {
        setLoading(true);
        const response = await getProductById(productId);

        if (response.success) {
          setProduct(response.data);
          setError(null);
        } else {
          setError(response.message || "获取产品详情失败");
        }
      } catch (err) {
        setError("获取产品详情时发生错误");
        console.error(err);
      } finally {
        setLoading(false);
      }
    };

    loadProductDetail();
  }, [params?.id]);

  // 返回列表页
  const handleBackToList = () => {
    router.push("/products");
  };

  if (loading) {
    return (
      <div className="container mx-auto px-4 py-8 text-center">
        <div className="text-2xl">加载中...</div>
      </div>
    );
  }

  if (error || !product) {
    return (
      <div className="container mx-auto px-4 py-8">
        <div className="bg-red-100 text-red-700 p-4 rounded mb-4">
          {error || "找不到产品信息"}
        </div>
        <button
          onClick={handleBackToList}
          className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
        >
          返回产品列表
        </button>
      </div>
    );
  }

  return (
    <div className="container mx-auto px-4 py-8">
      <button
        onClick={handleBackToList}
        className="mb-4 px-4 py-2 bg-gray-200 text-gray-700 rounded hover:bg-gray-300 flex items-center"
      >
        <span>← 返回产品列表</span>
      </button>

      <div className="bg-white rounded-lg shadow-md overflow-hidden">
        <div className="md:flex">
          <div className="md:w-1/2">
            {product.imagePath ? (
              <img
                src={product.imagePath}
                alt={product.name}
                className="w-full h-full object-cover"
              />
            ) : (
              <div className="flex items-center justify-center h-64 bg-gray-200 text-gray-500">
                暂无图片
              </div>
            )}
          </div>

          <div className="p-6 md:w-1/2">
            <div className="flex justify-between items-start">
              <h1 className="text-3xl font-bold mb-4">{product.name}</h1>
              {product.hot && (
                <span className="bg-red-500 text-white text-sm px-3 py-1 rounded-full">
                  热门产品
                </span>
              )}
            </div>

            <div className="mb-6 pb-6 border-b">
              <div className="grid grid-cols-2 gap-4">
                <div>
                  <p className="text-gray-600">产品类型</p>
                  <p className="font-semibold">
                    {productTypes[product.type as keyof typeof productTypes] ||
                      "未知"}
                  </p>
                </div>

                <div>
                  <p className="text-gray-600">炭黑含量</p>
                  <p className="font-semibold">{product.carbonBlack}</p>
                </div>

                <div>
                  <p className="text-gray-600">灰分</p>
                  <p className="font-semibold">{product.ashContent}</p>
                </div>

                <div>
                  <p className="text-gray-600">分散性</p>
                  <p className="font-semibold">{product.dispersibility}</p>
                </div>
              </div>
            </div>

            <div className="flex space-x-4">
              <button className="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600">
                联系我们
              </button>

              <button className="px-6 py-3 border border-blue-500 text-blue-500 rounded-lg hover:bg-blue-50">
                了解更多
              </button>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

8. 数据库管理与迁移

在实际开发过程中,数据库结构往往需要随着业务需求的变化而演变。TypeORM 提供了强大的迁移功能,帮助我们管理数据库架构变更。

8.1 创建迁移配置

首先,在项目根目录创建 typeorm.config.ts 文件:

typescript 复制代码
import "reflect-metadata";
import { DataSource } from "typeorm";
import * as path from "path";
import * as dotenv from "dotenv";

// 加载环境变量
dotenv.config();

// 创建数据源
export const AppDataSource = new DataSource({
  type: "mysql",
  host: process.env.DB_HOST || "localhost",
  port: parseInt(process.env.DB_PORT || "3306"),
  username: process.env.DB_USER || "root",
  password: process.env.DB_PASSWORD || "password",
  database: process.env.DB_NAME || "nextjs_db",
  entities: [path.join(__dirname, "src/entities/**/*.entity.{ts,js}")],
  migrations: [path.join(__dirname, "src/migrations/**/*.{ts,js}")],
  migrationsTableName: "migrations",
  logging: true,
  synchronize: false // 迁移时禁用同步
});

8.2 添加迁移脚本

package.jsonscripts 部分添加以下命令:

json 复制代码
{
  "scripts": {
    // 其他脚本...
    "typeorm": "typeorm-ts-node-commonjs",
    "migration:create": "npm run typeorm -- migration:create src/migrations/",
    "migration:generate": "npm run typeorm -- -d ./typeorm.config.ts migration:generate src/migrations/",
    "migration:run": "npm run typeorm -- -d ./typeorm.config.ts migration:run",
    "migration:revert": "npm run typeorm -- -d ./typeorm.config.ts migration:revert"
  }
}

8.3 生成迁移文件

当您更改实体定义后,可以自动生成迁移文件:

bash 复制代码
npm run migration:generate -- InitialMigration

这会根据您当前的实体与数据库结构的差异,自动生成迁移文件。

8.4 运行迁移

执行所有待执行的迁移:

bash 复制代码
npm run migration:run

如需回滚最后一次迁移:

bash 复制代码
npm run migration:revert

9. Docker 部署

使用 Docker 可以简化应用的部署流程,确保开发和生产环境的一致性。

9.1 创建 Dockerfile

在项目根目录创建 Dockerfile

dockerfile 复制代码
# 使用 Node.js 18 作为基础镜像
FROM node:18-alpine AS base

# 设置工作目录
WORKDIR /app

# 安装依赖
FROM base AS deps
COPY package.json yarn.lock* ./
RUN yarn install --frozen-lockfile

# 构建应用
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN yarn build

# 生产环境
FROM base AS runner
ENV NODE_ENV production

# 创建非root用户
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
USER nextjs

# 复制必要文件
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

# 设置环境变量
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"

# 暴露端口
EXPOSE 3000

# 启动命令
CMD ["node", "server.js"]

9.2 创建 docker-compose.yml

创建 docker-compose.yml 文件,用于同时启动应用和 MySQL 数据库:

yaml 复制代码
version: "3.8"

services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - DB_HOST=db
      - DB_PORT=3306
      - DB_USER=root
      - DB_PASSWORD=password
      - DB_NAME=nextjs_db
    depends_on:
      - db

  db:
    image: mysql:8
    ports:
      - "3306:3306"
    environment:
      - MYSQL_ROOT_PASSWORD=password
      - MYSQL_DATABASE=nextjs_db
    volumes:
      - mysql_data:/var/lib/mysql

volumes:
  mysql_data:

9.3 使用 docker-compose 部署

使用以下命令启动应用:

bash 复制代码
docker-compose up -d

停止应用:

bash 复制代码
docker-compose down

10. 项目优化和最佳实践

10.1 性能优化

  1. 合理使用缓存:对于频繁访问但不常变化的数据,可以使用 Redis 进行缓存
typescript 复制代码
// 示例:Redis缓存的产品服务
async getProductById(id: number): Promise<Products | null> {
  try {
    // 尝试从缓存获取
    const cachedProduct = await redisClient.get(`product:${id}`);
    if (cachedProduct) {
      return JSON.parse(cachedProduct);
    }

    // 从数据库获取
    const productRepository = await this.getRepository();
    const product = await productRepository.findOneBy({ id });

    // 设置缓存
    if (product) {
      await redisClient.set(`product:${id}`, JSON.stringify(product), {
        EX: 3600 // 1小时过期
      });
    }

    return product;
  } catch (error) {
    console.error(`获取产品 ID ${id} 失败:`, error);
    throw error;
  }
}
  1. 数据库索引优化:为常用查询字段添加索引
typescript 复制代码
@Entity("products")
export class Products {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ index: true }) // 添加索引
  name: string;

  @Column({ index: true }) // 添加索引
  type: number;

  // 其他字段...
}
  1. 分页和延迟加载:使用 TypeORM 的关系延迟加载优化性能
typescript 复制代码
@Entity("products")
export class Products {
  // ...

  @ManyToOne(() => Category)
  @JoinColumn({ name: "category_id" })
  category: Category;

  @OneToMany(() => ProductImage, image => image.product, { lazy: true })
  images: Promise<ProductImage[]>;
}

10.2 安全最佳实践

  1. 输入验证:使用 class-validator 验证所有输入
typescript 复制代码
export class CreateProductDto {
  @IsNotEmpty()
  @IsString()
  @Length(2, 100)
  name: string;

  @IsString()
  imagePath: string;

  @IsNumber()
  @Min(1)
  @Max(3)
  type: number;

  @IsNumber()
  @IsOptional()
  carbonBlack: number;

  @IsNumber()
  @IsOptional()
  ashContent: number;

  @IsNumber()
  @IsOptional()
  dispersibility: number;

  @IsBoolean()
  @IsOptional()
  hot: boolean;
}
  1. 使用环境变量存储敏感信息:确保数据库凭证等敏感信息通过环境变量注入

  2. 实现 API 认证:使用 JWT 或其他认证机制保护 API 端点

typescript 复制代码
// 中间件:验证 API 令牌
export async function validateApiToken(req: NextRequest) {
  const token = req.headers.get("Authorization")?.split(" ")[1];

  if (!token) {
    return new Response(JSON.stringify({ message: "未提供授权令牌" }), {
      status: 401,
      headers: { "Content-Type": "application/json" }
    });
  }

  try {
    // 验证令牌
    const decoded = jwt.verify(token, process.env.JWT_SECRET!);
    return null; // 验证通过
  } catch (error) {
    return new Response(JSON.stringify({ message: "无效的授权令牌" }), {
      status: 401,
      headers: { "Content-Type": "application/json" }
    });
  }
}

// 在 API 路由中使用
export async function GET(request: NextRequest) {
  // 验证令牌
  const authError = await validateApiToken(request);
  if (authError) return authError;

  // 处理请求...
}

11. 结语

通过本教程,我们学习了如何使用 Next.js 和 MySQL 构建全栈应用,包括数据库配置、实体设计、服务层实现、API 路由开发和前端页面构建。这些知识和技能将帮助您开发功能完善、性能优异的 Web 应用程序。

随着项目的发展,您可能需要考虑引入更多高级功能,如用户认证、文件上传、实时通信等。Next.js 生态系统提供了丰富的工具和库,可以满足各种复杂需求。

希望本教程对您有所帮助,祝您开发愉快!

参考资源

相关推荐
小小小小宇24 分钟前
手写 zustand
前端
Hamm1 小时前
用装饰器和ElementPlus,我们在NPM发布了这个好用的表格组件包
前端·vue.js·typescript
小小小小宇2 小时前
前端国际化看这一篇就够了
前端
大G哥2 小时前
PHP标签+注释+html混写+变量
android·开发语言·前端·html·php
whoarethenext2 小时前
html初识
前端·html
柏油2 小时前
MySQL InnoDB 行锁
数据库·后端·mysql
小小小小宇2 小时前
一个功能相对完善的前端 Emoji
前端
m0_627827522 小时前
vue中 vue.config.js反向代理
前端
A-Kamen2 小时前
MySQL 存储引擎对比:InnoDB vs MyISAM vs Memory
数据库·mysql·spark
Java&Develop2 小时前
onloyoffice历史版本功能实现,版本恢复功能,编辑器功能实现 springboot+vue2
前端·spring boot·编辑器