使用Nextjs学习(学习+项目完整版本)

创建项目

运行如下命令

js 复制代码
npx create-next-app next-create

创建项目中出现的各种提示直接走默认的就行,一直回车就行了 创建完成后进入到项目运行localhost:3000访问页面,如果和我下面页面一样就是创建项目成功了

整理项目

  • 将app/globals.css里面的样式都删除,只留下最上面三行即可
  • 将app/layout.js的类名添加一个补充,换成 className={${inter.className} h-screen} 相当于给了高度100vh撑满

页面与布局

将app/Layout.js进行改造

js 复制代码
import { Inter } from "next/font/google";
import "./globals.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata = {
  title: "Create Next App",  // 网站标题
  description: "Generated by create next app", // 描述信息
};

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body className={inter.className}>
        app下面的layout
        {children}</body>
    </html>
  );
}

新建app/user/Layout.js存入以下内容

js 复制代码
export default function userLayout({ children }) {
  return (
    <section>
        user下面的layout
        {children}
    </section>
  );
}

访问localhost:3000

访问localhost:3000/user

通过对比可以发现,app下面的就是公共的根样式,下面每个Layout.js都会继承到,然后每个文件夹下都可以定义当前路由页面的样式

路由创建

静态路由

在app下面新建demo文件夹,在里面新建page.js和layout.js,内容如下

js 复制代码
// page.js
export default function(){
    return <div>12121</div>
}

// layout.js
export default function({children}){
    return <div>我是demo的母版 <br/>{children}</div>
}

访问:http://localhost:3000/demo 效果图 说明:app下面每个文件夹名称都是一个路由,每个文件夹下面的page.js就是代表当前文件夹的页面,每个文件夹又都有自己的layout.js(layout.js不是必须的,页面内不写这个文件也没关系),里面默认的参数children就是当前页面的元素,也可以说layout.js就相当于当前页面的母版,每个路由都有自己的layout.js但是app下面的layout.js是全局公共的

如果demo下面还有list这个页面,则在demo文件夹内list文件夹,然后在里面创建page.js文件即可,在里面写内容后,页面上面直接访问localhost:3000/demo/list即可

动态路由

当我们访问localhost:3000/demo/list/1或者localhost:3000/demo/list/2这种动态路由怎么实现呢???

这就要用到动态参数了

在app/demo/list下面新建[id]文件夹,这种文件夹名字是[]包裹的就是动态参数了

例如我们的[id]文件夹里面创建page.js内容如下

js 复制代码
export default function({params}){
    return <div>动态参数的值为{params.id}</div>
}

当访问http://localhost:3000/demo/list/2001时,页面效果如下

上面有个弊端就是只支持一级动态参数,如果希望多级的话可以将[id]文件名换成[...id]这样就是可以匹配到后面所有参数,访问localhost:3000/demo/list/2/3/4/5

效果图

路由组

项目下新建三个路径文件

  • app/(marketing)/about/page.js
  • app/(marketing)/bolg/page.js
  • app/(marketing)/(shop)/acconut/page.js

在每个page.js里面随便写点内容,访问以下路径

  • localhost:3000/about
  • localhost:3000/bolg
  • localhost:3000/account

可以发现都能被访问到,总结规律就是文件夹名字带括号的相当于可以忽略了

路由组不参与url的设定的

个人感觉唯一作用是用于设置共同的Layout.js

创建如下两个Layout.js文件

  • app/(marketing)/Layout.js
  • app/(marketing)/(shop)/Layout.js

在这两个里面添加如下代码

js 复制代码
export default function userLayout({ children }) {
  return (
    <section>
        marketing下面的layout
        {children}
    </section>
  );
}
js 复制代码
export default function userLayout({ children }) {
  return (
    <section>
        marketing下面shop的layout
        {children}
    </section>
  );
}

运行后会发现,marking的Layout,js被它里面所有文件所共用,shop里面的Layout.js被shop里面的文件所共用,因为这个案例shop在marking里面的,因此shop里面的文件也共用marking里面的样式,这就是路由组,按照上面传统的方式建路由,需要每个文件单独设置自己的Layout.js,使用路由组可以达到复用性

路由跳转和传参以及接收参数

跳转和传参

这里跳转的路径为app/list/[id]/page.js

js 复制代码
'use client';
import Link from "next/link";
import { useRouter } from "next/navigation";

function Home({ items, time }) {
  const router = useRouter()
  return (
    <div className="Home">
     <Link href={'/list/1'}>跳转到xiaoji页面</Link>
     <br></br>
     <Link href={'/list/1?name=萧寂'}>跳转到xiaoji页面(带参数)</Link>
     <hr></hr>
     <button onClick={()=>{router.push('/list/1')}}>跳转到xiaoji页面</button>
     <br></br>
     <button onClick={()=>{router.push('/list/1?name=萧寂')}}>跳转到xiaoji页面(第一种带参数)</button>
     <button onClick={()=>{router.push({
      pathname:'/list/1',
      query:{
        name:'萧寂'
      }
     })}}>跳转到xiaoji页面(第二种带参数)</button>
    </div>
  );
}
export default Home;

接收参数

app/list/[id]/page.js里面代码如下

js 复制代码
export default async function({params,searchParams}){
    await new Promise((resolve) => setTimeout(resolve, 2000));
    return <div>动态参数的值为{params.id},query动态的搜索参数为{searchParams.name}</div>
}

当我在页面输入地址localhost:3000/list/3?name=xiaoji,效果如下:

Loadding加载和流的处理

1.在app下面新建loading.js组件

js 复制代码
export default function(){
    return <div className={"text-2xl text-pink-400"}>Loading...</div>
}

2.修改app/Layout.js

js 复制代码
import { Inter } from "next/font/google";
import { Suspense } from "react"; 
import Loading from './loading';  // 引入app/loading.js
import "./globals.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata = {
  title: "Create Next App",  // 网站标题
  description: "Generated by create next app", // 描述信息
};

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Suspense fallback={<Loading></Loading>}> // 2.使用SusPense将页面包裹
        	app下面的layout
        	{children}
        </Suspense>
        </body>
    </html>
  );
}

子组件代码

这里使用了async/await模拟了一下异步,这是个细节,因为上面的loadding效果如果要出来的话,页面数据必须要是有异步效果,因为我没注意到这点,费了点时间才搞明白

js 复制代码
export default async function Posts() {
  await new Promise((resolve) => setTimeout(resolve, 2000));
  return <div>1111</div>;
}

注意:将Lodding放到app/Layout.js里面包裹的话,则针对所有页面生效,如果某个页面有不一样的loading效果的话,则需要在当前文件夹里面的Layout.js去单独引入对应的loading.js,可以在当前文件夹里面创建个loading.js,这样的话loading.js的样式仅仅作用于当前文件夹下的所有页面

js 复制代码
import { Suspense } from "react";
export default function userLayout({ children }) {
  return (
    <section>
        // 这个loading效果仅作用于当前文件夹下面的所有页面
        <Suspense fallback={<div className={"text-2xl text-pink-400"}>Loading...</div>}>
        user下面的layout
        {children}
      </Suspense>
    </section>
  );
}

注意:必须是渲染的页面内有异步操作(如async/await)才会有Loading.js效果

错误处理

新建app/error.js,放入以下内容

js 复制代码
'use client'

export default function({error,reset}){
    return (
        <div>
            <h2>我是全局的错误样式处理</h2>
            <button onClick={()=>reset()}>重试一下</button>
        </div>
    )
}

也可以对每个页面单独定义路由样式,只需要在目标页面的文件夹内新建error.js,放入以下内容即可

例如我在app/user/error.js内加入以下内容

js 复制代码
'use client'

export default function({error,reset}){
    return (
        <div>
            <h2>app/user 页面内有错误啦!!!</h2>
            <button onClick={()=>reset()}>重试一下</button>
        </div>
    )
}

例如我们在目标的user页面加入一些错误信息

js 复制代码
export default function Posts() {
  console.log('a',a);  // 这里没有a变量,因此这里会报错
  return <div>1111</div>;
}

当我们在浏览器访问localhost:3000/user就会报出以下错误

当我们访问其他页面有错误信息时,但是没有给那个页面单独定义错误样式,则会触发全局的错误样式

例如访问: localhost:3000/about

链接和导航

修改下app/page.js内容如下

js 复制代码
'use client';
import Link from "next/link";
import { useRouter } from "next/navigation";


export default function Home() {
  const router = useRouter()
  return (
    <>
    <h1 className="text-4xl text-orange-600">Hello Name</h1>
    <br></br>
    <Link href={"/user"}>跳转到user路由</Link>
    <br></br>
    <button onClick={()=>{router.push('/user')}}>点击跳转/user</button>
    </>
  );
}

滚动到新路由的指定位置处,相当于锚点链接

js 复制代码
<Link href="/dashboard#settings">Settings</Link>
 
// Output
<a href="/dashboard#settings">Settings</a>

平行路由(Parallel Routes)

在app下面新建@home和@setting文件夹,里面都新建一个page.js文件,在里面写一点页面

在app/Layout.js里面改为如下页面代码

js 复制代码
import { Inter } from "next/font/google";
import { Suspense } from "react";
import Loading from './loading';
import "./globals.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata = {
  title: "Create Next App",  // 网站标题
  description: "Generated by create next app", // 描述信息
};

export default function RootLayout({ children,home,setting }) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Suspense fallback={<Loading></Loading>}>
        app下面的layout
        {home}
        {children}
        {setting}
        </Suspense>
        </body>
    </html>
  );
}

可以发现组件效果已经出来了

这里有个注意点,使用了平行路由后,这个文件夹内不要创建别的路由文件夹了,因为就算创建了后面的路径也会报错一堆404,我不知道是正常情况还是异常情况,本人就这样理解的,因此把所有的需要用到平行路由的界面我都归类为最终页面了

组件化渲染

下面是可以应用到我们页面里面的组件的案例

在app平级处创建components/frame/index.js,放入以下内容

js 复制代码
import Image from "next/image";

export default function({photo}){
    console.log('photo',photo);
    return <>
    <Image src={photo.src} alt="" width={600} height={600} className={'w-full object-cover aspect-square col-span-2 w-28'}></Image>
    </>
}

在app里面新建photo/page.js文件,插入以下内容

js 复制代码
import Photo from "@/components/frame"  // 引入组件
export default function(){
    const photo = {src:'https://take-saas.oss-cn-hangzhou.aliyuncs.com/wechat_applets/coach/bgcimg/bgc-13.png'}
    // 给组件传值
    return <Photo photo={photo}></Photo>
}

注意:这里可能会报错图片问题(网络图片需要加一下白名单才能正常加载,如下在next.config.mjs里面进行配置)

js 复制代码
/** @type {import('next').NextConfig} */
const nextConfig = {
    images:{
        domains:['take-saas.oss-cn-hangzhou.aliyuncs.com'] // 这里是存放域名白名单处
    }
};

export default nextConfig;

然后就可以看到图片正常加载了

定义404页面

在app下面新建not-found.js,放入以下内容

js 复制代码
export default function(){
    return <div className={"text-2xl text-pink-400"}>访问页面不存在...</div>
}

当页面访问一个不存在的页面路由时,页面显示效果如下

自定义页面网站标题(有利于SEO)

静态设置meta标签

在任意page.js内都按照下面这样写就行

js 复制代码
export const metadata = {
    title: "我是list页面",  // 页面标题
    description: "list页面描述",  // 页面描述
    keywords:"list页面,nextjs开发,测试页面"  // 关键词
  };
export default function(){
    return <div>list</div>
}

只要在页面内部导出metadata 即可,里面写好网站title和描述信息,这是一种约束,nextjs默认你导出这个就是设置title和描述的 效果图

动态设置meta标签

例如我在app/list/[id]/page.js加入以下代码

js 复制代码
export async function generateMetadata({params,searchParams}){
    // 这个方法是异步方法
    return {
      title:`这是动态参数:${params.id}-${searchParams.name}`,  // 这个id其实就是动态路由的动态参数,跟下面的页面接收到的params值一样
    }
  }

export default async function({params,searchParams}){
    await new Promise((resolve) => setTimeout(resolve, 2000));
    return <div>动态参数的值为{params.id},query动态的搜索参数为{searchParams.name}</div>
}

在页面输入地址localhost:3000/list/3?name=xiaoji,效果如下:

编写Api接口

静态路径

在app下面新建api文件夹,在里面新建goods文件夹在里面再次新建route.js文件,放入以下内容 主意api文件夹一定是要放到app文件夹下面的,并且接口文件都是route.js命名跟page.js一样是约定名称,固定的

js 复制代码
import { NextRequest,NextResponse } from "next/server";

export const GET = ()=>{
    return NextResponse.json({
        succes:true,
        errorMessage:"获取数据",
        data:{}
    })
}

重启项目访问http://localhost:3000/api/goods,效果如下

动态路径

在app下面新建api文件夹,在里面新建goods文件夹,再新建[id]文件夹,在里面再次新建route.js文件,放入以下内容

js 复制代码
import { NextRequest,NextResponse } from "next/server";

export const GET = (req,{params})=>{
    return NextResponse.json({
        succes:true,
        errorMessage:"获取单条记录"+params.id,
        data:{}
    })
}

浏览器访问:http://localhost:3000/api/goods/123

发送接口请求

注意:在app文件夹下面所有文件夹名字前面带_的都不会被自动解析为路由

  1. 在app/list下面新建_components作为组件文件夹,在里面新建list.js写客户端组件进行发请求,代码如下:
js 复制代码
"use client"
import { useEffect,useState } from "react"
export default function(){
    const [data,setData] = useState([]) 
    useEffect(()=>{
        fetch("api/goods").then((res)=>res.json()).then((res)=>{
            setData(res.data)
        })
    },[])
    return <div>
        <ul>
            {
                data.map((item,index)=>{
                    return <li key={item.id}>{item.name}</li>
                })
            }
        </ul>
    </div>
}
  1. 在app/list/page.js里面引入组件
js 复制代码
import List from "./_components/list";

export default function(){
    return <List></List>
}
  1. 访问http://localhost:3000/list,效果图如下

数据库引入

在前端使用这个用于操作数据库的库www.prisma.io/

1.安装库

js 复制代码
yarn add prisma --save-dev
// 下面这个安装官网没有,但是我报了一个错误是因为没有这个模块导致的,如果跟我一样报错的话就手动安装一下
yarn add @prisma/client

2.初始化数据库(sqlite数据库)

js 复制代码
npx prisma init --datasource-provider sqlite

3.在schema.prisma文件内新增一个model模型,也就是表

js 复制代码
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

// 新增如下
model Goods{
  id String @id @unique @default(uuid())
  name String
  desc String @default("")
  content String @default("") 
  createAt DateTime @default(now()) @map("create_at") // @map起别名,默认当前时间
  updateAt DateTime @updatedAt @map("update_at")
  @@map("products") // 表名
}

4.生成数据库(项目下终端执行命令)

js 复制代码
npx prisma db push

5.这时候就会看到prisma下面多了一个dev.db数据库文件了

6.使用数据库软件navicat打开这个数据库就可以发现刚刚我们新建的表

7.连接数据库(在prisma官网搜Solution这个关键词,下面这段代码即可,也可以直接复制我的也是一样的)

app同级位置新建文件db.js放入以下内容

js 复制代码
import { PrismaClient } from '@prisma/client'

const prismaClientSingleton = () => {
  return new PrismaClient()
}

const prisma = globalThis.prismaGlobal ?? prismaClientSingleton()

export default prisma

if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma

8.将刚刚的/api/good/route.js文件做修改,做两个接口,新增和修改,代码如下

js 复制代码
import { NextRequest,NextResponse } from "next/server";
import prisma from '@/db'
export const GET = async ()=>{
    // 查询数据,根据时间倒序排序
    // 创建的模型名称小写的(goods)
    const data =await prisma.goods.findMany({
        orderBy:{
            createAt:"desc",
        }
    })
    return NextResponse.json({
        succes:true,
        successMessage:"获取数据成功",
        data
    })
}

// 新增数据
export const POST = async (req)=>{
    const data = await req.json() // 获取请求体中传递的json数据
    await prisma.goods.create({
        data,
    })
    return NextResponse.json({
        succes:true,
        successMessage:"创建数据成功",
        data:{}
    })
}

9:使用apiFox等工具添加数据(post请求,端口号3000)

接口地址为:http:localhost:3000/api/goods 参数:点击body->json->输入{name:'萧寂'} 发请求:点击send

10:数据创建成功后刷新下数据库,使用数据库软件查看数据

11:在浏览器访问上面发送接口请求的那个页面,看看是否显示了数据

访问:http:localhost:3000/list 可以发现,数据确实被加进来了,至此添加和查询数据的接口准备完毕

项目阶段(做个管理后台)

引入Antd组件库

官方网站 安装

js 复制代码
yarn add antd

随便找个页面放入antd组件查看页面是否有效果,我这里在app/page.js放入以下内容

js 复制代码
import React from 'react';
import { DatePicker } from 'antd';

const App = () => {
  return <DatePicker />;
};

export default App;

浏览器访问:http://localhost:3000/ 效果图

这样就代表组件库已经引入进来了

但是如果发现样式和antd样式不一致,就是tailwindcss和其样式冲突了,我们需要在tailwind.config.js进行配置

js 复制代码
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./pages/**/*.{js,ts,jsx,tsx,mdx}",
    "./components/**/*.{js,ts,jsx,tsx,mdx}",
    "./app/**/*.{js,ts,jsx,tsx,mdx}",
  ],
  theme: {
    extend: {
      backgroundImage: {
        "gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
        "gradient-conic":
          "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
      },
    },
  },
  plugins: [],
  corePlugins:{
    preflight:false  // 新增,页面就正常了
  }
};

项目结构搭建和配置

在app下面新建admin文件夹

在admin下面新建_components/AntdAdmin.js组件作为管理系统主页面容器,内容如下(这个后台管理布局直接从antd组件库的layout直接复制粘贴来的)

js 复制代码
"use client";
import {Button, Layout, Menu, theme } from "antd";
import "antd/dist/reset.css";
import {
  MenuFoldOutlined,
  MenuUnfoldOutlined,
  UploadOutlined,
  UserOutlined,
  VideoCameraOutlined,
} from "@ant-design/icons";
import { useState } from "react";
const { Header, Sider, Content } = Layout;
export default function ({ children }) {
  const [collapsed, setCollapsed] = useState(false);
  const {
    token: { colorBgContainer, borderRadiusLG },
  } = theme.useToken();
  return (
    // 设置语言包
    <>
      <Layout style={{height:'100vh'}}>
        <Sider trigger={null} collapsible collapsed={collapsed}>
          <div className="demo-logo-vertical" />
          <Menu
            theme="dark"
            mode="inline"
            defaultSelectedKeys={["1"]}
            items={[
              {
                key: "1",
                icon: <UserOutlined />,
                label: "nav 1",
              },
              {
                key: "2",
                icon: <VideoCameraOutlined />,
                label: "nav 2",
              },
              {
                key: "3",
                icon: <UploadOutlined />,
                label: "nav 3",
              },
            ]}
          />
        </Sider>
        <Layout>
          <Header
            style={{
              padding: 0,
              background: colorBgContainer,
            }}
          >
            <Button
              type="text"
              icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
              onClick={() => setCollapsed(!collapsed)}
              style={{
                fontSize: "16px",
                width: 64,
                height: 64,
              }}
            />
          </Header>
          <Content
            style={{
              margin: "24px 16px",
              padding: 24,
              minHeight: 280,
              background: colorBgContainer,
              borderRadius: borderRadiusLG,
            }}
          >
            {children}
          </Content>
        </Layout>
      </Layout>
    </>
  );
}

在admin/(admin-layout)下面新建layout.js作为管理系统母版文件,放入以下内容

js 复制代码
import AntdAdmin from "../_conponents/AntdAdmin";

export default function({children}){
    return <AntdAdmin>{children}</AntdAdmin>
}

在admin下新建(admin-layout)/dashboard文件夹,里面新建page.js,这个作为管理系统主页面(带layout侧边栏的),内容如下

js 复制代码
import { Card } from "antd";

export default function(){
    return <Card title="这是首页">111</Card>
}

在dashboard文件夹下面新建_components文件夹,作为主页面引入的组件的容器

在admin下面新建login/page.js,作为项目的登录页面,其内容如下:

这里用(这种路由组的原因是登录注册页面不能放到管理界面布局里面,也就是说注册登录页面要在(admin-layout)外面)

js 复制代码
"use client"
import {Card,Form,Button,Input} from 'antd'
import {useRouter} from 'next/navigation'
export default function(){
    const router = useRouter()
    return <div className='pt-20'>
        <Card title="Next全栈管理后台" className='mx-auto w-4/5'>
            <Form labelCol={{span:3}} onFinish={async (v)=>{
                // 表单提交后可以通过形参v拿到表单内的数据
                // console.log('v',v); // 可以直接拿到输入的数据
                const res = await fetch('/api/admin/login',{
                    method:'post',
                    body:JSON.stringify(v)
                }).then(res=>res.json())
                console.log('res',res);
 
                // 跳转到首页
                router.push('/admin/dashboard')
            }}>
                <Form.Item name="username" label="用户名">
                    <Input placeholder='请输入用户名'/>
                </Form.Item>
                <Form.Item name="password" label="密码">
                    <Input.Password placeholder='请输入密码'/>
                </Form.Item>
                <Form.Item label="用户名">
                    {/* 点击登录触发表单提交事件 */}
                    <Button block type='primary' htmlType='submit'>登录</Button>
                </Form.Item>
            </Form>
        </Card>
    </div>
}

效果图如下:

整体结构如下

中间件做登录判断

在app同级目录中创建middleware.js(这个也是约定好的名称,固定的),内容如下(也可以去nextjs官网搜:Middleware 关键词下拉找到这段代码,下面的是改造后的):

js 复制代码
import { NextResponse } from 'next/server'
 
// This function can be marked `async` if using `await` inside
export function middleware(request) {
    console.log('中间执行了');
    if(request.nextUrl.pathname.startsWith('/admin')){
        // 如果访问的是管理后台页面,则进行一下判断
        // 访问的是登录页面可以直接放行
        if(!request.nextUrl.pathname.startsWith('/admin/login')){
            // 不是登录页面的话,判断是否登陆过,这里取cookie
            if(request.cookies.get('admin-token')){
                // 如果有cookie,代表已经登陆了,什么都不做
            }else{
                // 未登录,跳转到登录页面
                return NextResponse.redirect(new URL('/admin/login',request.url))
            }
        }
    }
}

在app/api里面新建admin/login/route.js(做登录接口)

内容如下(这里也是做了一个假的,目的用于发送接口请求设置cookie,在真实的业务场景中是去数据库查找某个人信息查找到了返回登陆成功,动态设置cookie)

js 复制代码
import { NextRequest,NextResponse } from "next/server";
import prisma from '@/db'
export const POST = async (req)=>{
    return NextResponse.json({
        success:true,
        successMessage:'登陆成功',
    },{
        headers:{
            'Set-Cookie':'admin-token=123;Path=/',
        }
    }
)
}

修改app/admin/login/page.js内容(发起登录跳转)

这里也没有判断是否成功请求接口,这里只加了一个请求到接口就跳转,也就是设置完cookie就跳转,因为我们在上面做了cookie的判断,不设置好cookie不会成功跳转的

js 复制代码
"use client"
import {Card,Form,Button,Input} from 'antd'
import {useRouter} from 'next/navigation'
export default function(){
    const router = useRouter()
    return <div className='pt-20'>
        <Card title="Next全栈管理后台" className='mx-auto w-4/5'>
            <Form labelCol={{span:3}} onFinish={async (v)=>{
                // console.log('v',v); // 可以直接拿到输入的数据
                const res = await fetch('/api/admin/login',{
                    method:'post',
                    body:JSON.stringify(v)
                }).then(res=>res.json())
                console.log('res',res);
                // 跳转到首页
                router.push('/admin/dashboard')
            }}>
                <Form.Item name="username" label="用户名">
                    <Input placeholder='请输入用户名'/>
                </Form.Item>
                <Form.Item name="password" label="密码">
                    <Input.Password placeholder='请输入密码'/>
                </Form.Item>
                <Form.Item label="用户名">
                    <Button block type='primary' htmlType='submit'>登录</Button>
                </Form.Item>
            </Form>
        </Card>
    </div>
}

新增用户信息页面

在app/admin/(admin-layout)下新增users/pages.js,内容如下

js 复制代码
'use client'
import { Button, Card, Form, Input, Table } from "antd";
import {SearchOutlined,PlusOutlined} from '@ant-design/icons'
export default function(){
    return <Card title={"用户信息"} extra={<><Button icon={<PlusOutlined />} type="primary"/></>}>
        <Form layout="inline">
            <Form.Item label="姓名">
                <Input placeholder="请输入姓名"></Input>
            </Form.Item>
            <Form.Item>
                <Button icon={<SearchOutlined />} type='primary'></Button>
            </Form.Item>
        </Form>
        <Table style={{marginTop:'8px'}}
        columns={[
            {
                title:'序号'
            },
            {
                title:'姓名'
            },
            {
                title:'昵称'
            },
            {
                title:'用户名'
            },
            {
                title:'头像'
            },
            {
                title:'手机号'
            },
            {
                title:'年龄'
            },
            {
                title:'性别'
            },
            {
                title:'操作'
            }
        ]}
        >

        </Table>
    </Card>
}

实现layout侧边栏之间的导航切换

修改app/admin/_components/AntdAdmin.js如下

js 复制代码
"use client";
import {Button, Layout, Menu, theme } from "antd";
import "antd/dist/reset.css";
import {
  MenuFoldOutlined,
  MenuUnfoldOutlined,
  UploadOutlined,
  UserOutlined,
  VideoCameraOutlined,
} from "@ant-design/icons";
import { useState } from "react";
import { useRouter } from "next/navigation";
const { Header, Sider, Content } = Layout;
export default function ({ children }) {
    const router = useRouter()
  const [collapsed, setCollapsed] = useState(false);
  const {
    token: { colorBgContainer, borderRadiusLG },
  } = theme.useToken();
  return (
    // 设置语言包
    <>
      <Layout style={{height:'100vh'}}>
        <Sider trigger={null} collapsible collapsed={collapsed}>
          <div className="demo-logo-vertical" />
          <Menu
            theme="dark"
            mode="inline"
            defaultSelectedKeys={["1"]}
            onClick={({key})=>{
                // 点击面板跳转对应右侧页面显示内容
                router.push(key)
            }}
            items={[
              {
                key: "/admin/dashboard",
                icon: <UserOutlined />,
                label: "看板",
              },
              {
                key: "/admin/users",
                icon: <VideoCameraOutlined />,
                label: "用户信息",
              },
              {
                key: "/admin/articles",
                icon: <UploadOutlined />,
                label: "文章管理",
              },
            ]}
          />
        </Sider>
        <Layout>
          <Header
            style={{
              padding: 0,
              background: colorBgContainer,
            }}
          >
            <Button
              type="text"
              icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
              onClick={() => setCollapsed(!collapsed)}
              style={{
                fontSize: "16px",
                width: 64,
                height: 64,
              }}
            />
          </Header>
          <Content
            style={{
              margin: "24px 16px",
              padding: 24,
              minHeight: 280,
              background: colorBgContainer,
              borderRadius: borderRadiusLG,
            }}
          >
            {children}
          </Content>
        </Layout>
      </Layout>
    </>
  );
}

效果图

文章列表和新增文章实现

在(admin-layout)文件夹下面新增articles/page.js页面,这个就是文章管理页面,代码如下

js 复制代码
'use client'
import { Button, Card, Form, Input, Modal, Table } from "antd";
import {SearchOutlined,PlusOutlined} from '@ant-design/icons'
import { useState } from "react";
export default function(){
    const [open,setOpen] = useState(false) // 控制弹窗显示隐藏
    const [myForm] = Form.useForm(); // 获取Form组件
    return <Card title={"文章管理"} extra={<><Button onClick={()=>{setOpen(true)}} icon={<PlusOutlined />} type="primary"/></>}>
        <Form layout="inline">
            <Form.Item label="姓名">
                <Input placeholder="请输入姓名"></Input>
            </Form.Item>
            <Form.Item>
                <Button icon={<SearchOutlined />} type='primary'></Button>
            </Form.Item>
        </Form>
        <Table style={{marginTop:'8px'}}
        columns={[
            {
                title:'序号'
            },
            {
                title:'标题'
            },
            {
                title:'简介'
            },
            {
                title:'操作'
            }
        ]}
        >
        </Table>
        <Modal title="编辑" open={open} onCancel={()=>setOpen(false)} onOk={()=>{
            // 模态框点击确认触发表单提交事件
            myForm.submit();
        }}>
        <Form layout="vertical"  form={myForm} onFinish={(v)=>{
            // 上面触发了表单提交后,这个表单的onFinish事件可以获取到表单内容
            // 这里就可以调用接口了
            console.log('v',v);
            // 关闭模态框
            setOpen(false)
        }}>
            <Form.Item label="姓名" name='title' rules={[{required:true,message:'标题不能为空'}]}>
                <Input placeholder="请输入姓名"></Input>
            </Form.Item>
            <Form.Item label="简介" name='desc'>
                <Input.TextArea placeholder="请输入简介"></Input.TextArea>
            </Form.Item>
        </Form>
        </Modal>
    </Card>
} 

实现文章管理界面的接口

先创建关于文章管理的数据库模型

找到prisma/schema.prisma新增如下模型

js 复制代码
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model Goods{
  id String @id @unique @default(uuid())
  name String
  desc String @default("")
  content String @default("") 
  createAt DateTime @default(now()) @map("create_at") // @map起别名,默认当前时间
  updateAt DateTime @updatedAt @map("update_at")
  @@map("products") // 表名
}

model Article{
  id String @id @unique @default(uuid())
  title String
  desc String? @default("")  // ?代表是可选参数
  content String? @default("") 
  image String? @default("") // 文章封面
  createAt DateTime @default(now()) @map("create_at") // @map起别名,默认当前时间
  updateAt DateTime @updatedAt @map("update_at")
  @@map("article") // 表名
}

在终端执行以下命令创建模型(更新数据库,不会影响之前数据库和数据):

js 复制代码
npx prisma db push

实现文章管理的增删改查接口

查询和新增数据

在app/api/admin下面新增articles/route.js文件,内容如下:

js 复制代码
import { NextRequest,NextResponse } from "next/server";
import prisma from '@/db'

// 查询文章
export const GET = async ()=>{
    const data = await prisma.article.findMany({
        where:{},
        orderBy:{
            createAt:'desc'
        }
    })
    return NextResponse.json({
        success:true,
        successMessage:'查询成功',
        data:{
            list:data
        }
    }
)
}

// 新增文章
export const POST=async (req)=>{
    const data = await req.json()
    await prisma.article.create({
        data
    })
    return NextResponse.json({
        success:true,
        successMessage:"创建成功",
        data:{}
    })
}

修改和删除数据

在app/api/admin下面新增articles/[id]/route.js文件,内容如下(因为修改和删除都是需要id参数的,因此这里用了动态路由)

js 复制代码
import { NextRequest,NextResponse } from "next/server";
import prisma from '@/db'

// 修改文章
export const PUT = async (req,res)=>{
    const {id} = res.params // 路由中传递的参数
    const data = await req.json() // 请求体中传递的参数
    await prisma.article.update({
        where:{id},
        data
    })
    return NextResponse.json({
        success:true,
        successMessage:'修改成功',
        data:{}
    }
)
}

// 删除数据
export const DELETE = async (req,res)=>{
    const {id} = res.params // 路由中传递的参数
    await prisma.article.delete({
        where:{id}
    })
    return NextResponse.json({
        success:true,
        successMessage:'删除成功',
        data:{}
    })
}

在app/admin/(admin-layout)新建articles/page.js文件,放入以下内容

js 复制代码
"use client";
import {
  Button,
  Card,
  Form,
  Input,
  Modal,
  Popconfirm,
  Space,
  Table,
} from "antd";
import {
  SearchOutlined,
  PlusOutlined,
  EditOutlined,
  DeleteOutlined,
} from "@ant-design/icons";
import { useEffect, useState } from "react";
export default function () {
  const [myForm] = Form.useForm(); // 获取Form组件
  const [open, setOpen] = useState(false); // 控制弹窗显示隐藏
  const [list, setList] = useState([]); // 数据
  const [dataItemId, setDataItemId] = useState(false); // 判断是新增还是修改,无id新增,有id是修改
  function getList() {
    fetch("/api/admin/articles")
      .then((res) => res.json())
      .then((res) => {
        setList(res.data.list);
      });
  }

  useEffect(() => {
    getList();
  }, []);

  useEffect(() => {});
  return (
    <Card
      title={"文章管理"}
      extra={
        <>
          <Button
            onClick={() => {
              setOpen(true);
              setDataItemId(""); // 将模式改为新增模式
            }}
            icon={<PlusOutlined />}
            type="primary"
          />
        </>
      }
    >
      <Form layout="inline">
        <Form.Item label="标题">
          <Input placeholder="请输入关键词"></Input>
        </Form.Item>
        <Form.Item>
          <Button icon={<SearchOutlined />} type="primary"></Button>
        </Form.Item>
      </Form>
      <Table
        dataSource={list}
        rowKey={"id"}
        style={{ marginTop: "8px" }}
        columns={[
          {
            title: "序号",
            render(v, r, i) {
              // 参数为当前单元格,当前单元行,当前索引
              return i + 1;
            },
          },
          {
            title: "标题",
            dataIndex: "title", // 这个是根据数组内对象的键进行绑定的
          },
          {
            title: "简介",
            dataIndex: "desc",
          },
          {
            title: "操作",
            render(v, r) {
              return (
                <Space>
                  <Button
                    type="primary"
                    size="small"
                    icon={<EditOutlined />}
                    onClick={() => {
                      setOpen(true);
                      setDataItemId(r.id); // 将模式改为修改模式
                      // 回显数据(将当前单元格数据回显给from)
                      setTimeout(() => {
                        myForm.setFieldsValue(r); // 这一行代码就能回显数据了(由于每次都是销毁model和form,因此每次打开时加个异步确保能得到myForm)
                      }, 50);
                    }}
                  ></Button>
                  {/* 删除的弹窗提示 */}
                  <Popconfirm
                    title="是否确认删除"
                    onConfirm={() => {
                      // 删除文章
                      fetch("/api/admin/articles/" + r.id, {
                        method: "DELETE",
                        body: JSON.stringify(v),
                      })
                        .then((res) => res.json())
                        .then((res) => {
                          // 重新获取数据
                          getList();
                        });
                    }}
                  >
                    <Button
                      type="primary"
                      danger
                      size="small"
                      icon={<DeleteOutlined />}
                    ></Button>
                  </Popconfirm>
                </Space>
              );
            },
          },
        ]}
      ></Table>
      <Modal
        title="编辑"
        open={open}
        onCancel={() => setOpen(false)}
        destroyOnClose={true} // 关闭窗口之后销毁(预防弹出时回显数据)
        maskClosable={false} // 点击空白区域不关闭弹窗
        onOk={() => {
          // 模态框点击确认触发表单提交事件
          myForm.submit();
        }}
      >
        <Form
          preserve={false} // 和model结合使用时加上这个,否则表单不会销毁
          layout="vertical"
          form={myForm}
          onFinish={(v) => {
            // 上面触发了表单提交后,这个表单的onFinish事件可以获取到表单内容
            // 这里就可以调用接口了
            console.log("v", v);
            if (dataItemId != "") {
              // 修改文章
              fetch("/api/admin/articles/" + dataItemId, {
                method: "PUT",
                body: JSON.stringify(v),
              })
                .then((res) => res.json())
                .then((res) => {
                  // 关闭模态框
                  setOpen(false);
                  // 重新获取数据
                  getList();
                });
            } else {
              // 新增文章
              fetch("/api/admin/articles", {
                method: "POST",
                body: JSON.stringify(v),
              })
                .then((res) => res.json())
                .then((res) => {
                  // 关闭模态框
                  setOpen(false);
                  // 重新获取数据
                  getList();
                });
            }
          }}
        >
          <Form.Item
            label="姓名"
            name="title"
            rules={[{ required: true, message: "标题不能为空" }]}
          >
            <Input placeholder="请输入姓名"></Input>
          </Form.Item>
          <Form.Item label="简介" name="desc">
            <Input.TextArea placeholder="请输入简介"></Input.TextArea>
          </Form.Item>
        </Form>
      </Modal>
    </Card>
  );
}

效果图

新增数据 修改数据

删除数据

实现分页查询功能

将上面的app/api/admin下面新增articles/route.js文件,修改如下(就是修改下查询接口,添加一下分页查询)

js 复制代码
import { NextRequest, NextResponse } from "next/server";
import prisma from "@/db";

// 查询文章
export const GET = async (req) => {
  // let per = 10 // 每页数量
  // let page = 1 // 当前页
  const per = req.nextUrl.searchParams.get("per") || 2; // 从参数取,取不到则取10
  const page = req.nextUrl.searchParams.get("page") || 1;
  const title = req.nextUrl.searchParams.get("title") || ""; // 根据title查询
  const data = await prisma.article.findMany({
    where: {
      // title:title // 精确查询
      title: {
        contains: title, // 模糊查询
      },
    },
    orderBy: {
      createAt: "desc",
    },
    take: per * 1, // 每页数据条数(要数字类型,这里转一下)
    skip: (page - 1) * per, // 跳过多少页
  });
  const total = await prisma.article.count({
    where: {
      title: {
        contains: title, // 模糊查询时总数量也会变化
      },
    },
  }); // 查询总数量
  return NextResponse.json({
    success: true,
    successMessage: "查询成功",
    data: {
      list: data,
      total,
    },
  });
};

// 新增文章
export const POST = async (req) => {
  const data = await req.json();
  await prisma.article.create({
    data,
  });
  return NextResponse.json({
    success: true,
    successMessage: "创建成功",
    data: {},
  });
};

浏览器访问路径如下: http://localhost:3000/api/admin/articles?per=10&page=1&title=111

修改app/admin/(admin-layout)/articles/page.js文件,加入分页查询功能的接口(内容如下)

js 复制代码
"use client";
import {
  Button,
  Card,
  Form,
  Input,
  Modal,
  Popconfirm,
  Space,
  Table,
} from "antd";
import {
  SearchOutlined,
  PlusOutlined,
  EditOutlined,
  DeleteOutlined,
} from "@ant-design/icons";
import { useEffect, useState } from "react";
export default function () {
  const [myForm] = Form.useForm(); // 获取Form组件
  const [open, setOpen] = useState(false); // 控制弹窗显示隐藏
  const [list, setList] = useState([]); // 数据
  const [dataItemId, setDataItemId] = useState(false); // 判断是新增还是修改,无id新增,有id是修改
  const [total, setTotal] = useState(0); // 总数量
  function getList(a, b, c = "") {
    fetch(`/api/admin/articles?per=${a}&page=${b}&title=${c}`)
      .then((res) => res.json())
      .then((res) => {
        console.log("res", res);
        setList(res.data.list);
        setTotal(res.data.total);
      });
  }

  useEffect(() => {
    getList(10, 1); // 默认的分页
  }, []);

  useEffect(() => {});
  return (
    <Card
      title={"文章管理"}
      extra={
        <>
          <Button
            onClick={() => {
              setOpen(true);
              setDataItemId(""); // 将模式改为新增模式
            }}
            icon={<PlusOutlined />}
            type="primary"
          />
        </>
      }
    >
      <Form
        layout="inline"
        onFinish={(v) => {
          console.log("v", v);
          getList(10, 1, v.title);
        }}
      >
        <Form.Item label="标题" name="title">
          <Input placeholder="请输入关键词"></Input>
        </Form.Item>
        <Form.Item>
          <Button
            htmlType="submit"
            icon={<SearchOutlined />}
            type="primary"
          ></Button>
        </Form.Item>
      </Form>
      <Table
        dataSource={list}
        rowKey={"id"}
        style={{ marginTop: "8px" }}
        pagination={{
          total, // 总数量
          pageSize: 10, // 每页条数
          onChange(page) {
            // 点击分页条发生的事件
            getList(10, page);
          },
          showTotal: (total) => {
            return `共 ${total} 条`;
          },
        }}
        columns={[
          {
            title: "序号",
            render(v, r, i) {
              // 参数为当前单元格,当前单元行,当前索引
              return i + 1;
            },
          },
          {
            title: "标题",
            dataIndex: "title", // 这个是根据数组内对象的键进行绑定的
          },
          {
            title: "简介",
            dataIndex: "desc",
          },
          {
            title: "操作",
            render(v, r) {
              return (
                <Space>
                  <Button
                    type="primary"
                    size="small"
                    icon={<EditOutlined />}
                    onClick={() => {
                      setOpen(true);
                      setDataItemId(r.id); // 将模式改为修改模式
                      // 回显数据(将当前单元格数据回显给from)
                      setTimeout(() => {
                        myForm.setFieldsValue(r); // 这一行代码就能回显数据了(由于每次都是销毁model和form,因此每次打开时加个异步确保能得到myForm)
                      }, 50);
                    }}
                  ></Button>
                  {/* 删除的弹窗提示 */}
                  <Popconfirm
                    title="是否确认删除"
                    onConfirm={() => {
                      // 删除文章
                      fetch("/api/admin/articles/" + r.id, {
                        method: "DELETE",
                        body: JSON.stringify(v),
                      })
                        .then((res) => res.json())
                        .then((res) => {
                          // 重新获取数据
                          getList();
                        });
                    }}
                  >
                    <Button
                      type="primary"
                      danger
                      size="small"
                      icon={<DeleteOutlined />}
                    ></Button>
                  </Popconfirm>
                </Space>
              );
            },
          },
        ]}
      ></Table>
      <Modal
        title="编辑"
        open={open}
        onCancel={() => setOpen(false)}
        destroyOnClose={true} // 关闭窗口之后销毁(预防弹出时回显数据)
        maskClosable={false} // 点击空白区域不关闭弹窗
        onOk={() => {
          // 模态框点击确认触发表单提交事件
          myForm.submit();
        }}
      >
        <Form
          preserve={false} // 和model结合使用时加上这个,否则表单不会销毁
          layout="vertical"
          form={myForm}
          onFinish={(v) => {
            // 上面触发了表单提交后,这个表单的onFinish事件可以获取到表单内容
            // 这里就可以调用接口了
            console.log("v", v);
            if (dataItemId != "") {
              // 修改文章
              fetch("/api/admin/articles/" + dataItemId, {
                method: "PUT",
                body: JSON.stringify(v),
              })
                .then((res) => res.json())
                .then((res) => {
                  // 关闭模态框
                  setOpen(false);
                  // 重新获取数据
                  getList();
                });
            } else {
              // 新增文章
              fetch("/api/admin/articles", {
                method: "POST",
                body: JSON.stringify(v),
              })
                .then((res) => res.json())
                .then((res) => {
                  // 关闭模态框
                  setOpen(false);
                  // 重新获取数据
                  getList();
                });
            }
          }}
        >
          <Form.Item
            label="姓名"
            name="title"
            rules={[{ required: true, message: "标题不能为空" }]}
          >
            <Input placeholder="请输入姓名"></Input>
          </Form.Item>
          <Form.Item label="简介" name="desc">
            <Input.TextArea placeholder="请输入简介"></Input.TextArea>
          </Form.Item>
        </Form>
      </Modal>
    </Card>
  );
}

实现文件上传(这里讲图片上传)

接口实现

在app/api下面新建common/upload/route.js文件,内容如下:

js 复制代码
import { NextRequest, NextResponse } from "next/server"; // 从 "next/server" 模块导入 NextRequest 和 NextResponse,用于处理请求和响应。
import dayjs from "dayjs"; // 导入 dayjs 库,用于日期格式化。
import path from "path"; // 导入 path 模块,用于处理文件路径。
import fs from "fs"; // 导入 fs 模块,用于文件系统操作。
import { randomUUID } from "crypto"; // 从 "crypto" 模块导入 randomUUID,用于生成随机 UUID。

// 定义一个异步函数,用于保存上传的文件
const saveFile = async (blob) => {
  const dirName = "/uploads/" + dayjs().format("YYYY-MM-DD"); // 根据当前日期生成存储目录路径
  const uploadDir = path.join(process.cwd(), "public" + dirName); // 生成完整的存储目录路径
  fs.mkdirSync(uploadDir, {
    recursive: true,
  }); // 如果存储目录不存在,则创建目录
  const fileName = randomUUID() + ".png"; // 生成唯一的文件名
  const arrayBuffer = await blob.arrayBuffer(); // 将文件 blob 转换为 ArrayBuffer
  fs.writeFileSync(uploadDir + "/" + fileName, new DataView(arrayBuffer)); // 将文件数据写入到指定路径
  return dirName + "/" + fileName; // 返回文件的存储路径
};

// 定义一个 POST 请求处理器
export const POST = async (req) => {
  const data = await req.formData(); // 从请求中提取表单数据
  const fileName = await saveFile(data.get('file')); // 保存上传的文件并获取文件路径
  return NextResponse.json({
    success: true, // 设置成功标志
    successMessage: "文件上传成功", // 设置成功消息
    data: fileName // 返回文件路径
  });
};

上面用的知识点有点多,注释详细点

  1. 模块导入:
  • NextRequest 和 NextResponse 是用于处理 HTTP 请求和响应的 Next.js API 模块。
  • dayjs 用于日期格式化,以生成日期路径。
  • path 和 fs 分别用于文件路径处理和文件系统操作。
  • randomUUID 用于生成唯一的文件名,以避免文件名冲突。
  1. saveFile 函数:
  • 生成一个目录路径,以 "/uploads/" 为根目录,加上当天的日期(YYYY-MM-DD)。
  • 使用 process.cwd() 获取当前工作目录的路径,并将其与 "public" 和 dirName 合并,生成完整的存储目录路径。
  • fs.mkdirSync 创建目录,如果目录不存在则递归创建。
  • randomUUID() 生成一个随机的 UUID,作为文件名,以保证文件名的唯一性。
  • blob.arrayBuffer() 将文件的 Blob 对象转换为 ArrayBuffer。
  • fs.writeFileSync 将文件数据写入到目标路径,使用 DataView 包装 ArrayBuffer 以便写入文件。
  1. POST 请求处理器:
  • 使用 req.formData() 从请求中获取表单数据。
  • 调用 saveFile 函数保存文件,并获取文件的存储路径。
  • 使用 NextResponse.json 返回 JSON 格式的响应,包含上传成功的标志、消息和文件路径。

创建上传文件组件

在app/admin/_components下面新建MyUploads.js文件,内容如下:(除了我写的注释123处,其他都是antd组件库粘贴的upload组件代码)

js 复制代码
import React, { useState } from "react";
import { LoadingOutlined, PlusOutlined } from "@ant-design/icons";
import { Flex, message, Upload } from "antd";

const beforeUpload = (file) => {
  const isJpgOrPng = file.type === "image/jpeg" || file.type === "image/png";
  if (!isJpgOrPng) {
    message.error("You can only upload JPG/PNG file!");
  }
  const isLt2M = file.size / 1024 / 1024 < 2;
  if (!isLt2M) {
    message.error("Image must smaller than 2MB!");
  }
  return isJpgOrPng && isLt2M;
};
// 1.组件接收两个参数
export default function ({imageUrl,setImageUrl}) {
  const [loading, setLoading] = useState(false);
  // 文件选择改变之后执行
  const handleChange = (info) => {
    if (info.file.status === "uploading") {
      setLoading(true);
      return;
    }
    if (info.file.status === "done") {
      console.log("info.file.response.data", info.file.response.data);
      setImageUrl(info.file.response.data); // 2.设置url路径
    }
  };
  const uploadButton = (
    <button
      style={{
        border: 0,
        background: "none",
      }}
      type="button"
    >
      {loading ? <LoadingOutlined /> : <PlusOutlined />}
      <div
        style={{
          marginTop: 8,
        }}
      >
        Upload
      </div>
    </button>
  );
  return (
    <>
      <Upload
        name="file" // 3.文件类型
        listType="picture-card"
        className="avatar-uploader"
        showUploadList={false}
        action="/api/common/upload" // 4.接口地址
        beforeUpload={beforeUpload}
        onChange={handleChange}
      >
        {imageUrl ? (
          <img
            src={imageUrl}
            alt="avatar"
            style={{
              width: "100%",
            }}
          />
        ) : (
          uploadButton
        )}
      </Upload>
    </>
  );
}

增加图片上传页面,修改文章管理页面代码(app/admin/(admin-layout)/articles/page.js),修改如下:

js 复制代码
"use client";
import {
  Button,
  Card,
  Form,
  Input,
  Modal,
  Popconfirm,
  Space,
  Table,
} from "antd";
import {
  SearchOutlined,
  PlusOutlined,
  EditOutlined,
  DeleteOutlined,
} from "@ant-design/icons";
import { useEffect, useState } from "react";
import MyUpload from "../../_conponents/MyUpload";
import Image from "next/image";
export default function () {
  const [myForm] = Form.useForm(); // 获取Form组件
  const [open, setOpen] = useState(false); // 控制弹窗显示隐藏
  const [list, setList] = useState([]); // 数据
  const [dataItemId, setDataItemId] = useState(false); // 判断是新增还是修改,无id新增,有id是修改
  const [total, setTotal] = useState(0); // 总数量
  const [imageUrl, setImageUrl] = useState(""); // 上传得到的url图片路径
  function getList(a, b, c = "") {
    fetch(`/api/admin/articles?per=${a}&page=${b}&title=${c}`)
      .then((res) => res.json())
      .then((res) => {
        console.log("res", res);
        setList(res.data.list);
        setTotal(res.data.total);
      });
  }

  useEffect(() => {
    getList(10, 1); // 默认的分页
  }, []);

  return (
    <Card
      title={"文章管理"}
      extra={
        <>
          <Button
            onClick={() => {
              setOpen(true);
              setDataItemId(""); // 将模式改为新增模式
            }}
            icon={<PlusOutlined />}
            type="primary"
          />
        </>
      }
    >
      <Form
        layout="inline"
        onFinish={(v) => {
          console.log("v", v);
          getList(10, 1, v.title);
        }}
      >
        <Form.Item label="标题" name="title">
          <Input placeholder="请输入关键词"></Input>
        </Form.Item>
        <Form.Item>
          <Button
            htmlType="submit"
            icon={<SearchOutlined />}
            type="primary"
          ></Button>
        </Form.Item>
      </Form>
      <Table
        dataSource={list}
        rowKey={"id"}
        style={{ marginTop: "8px" }}
        pagination={{
          total, // 总数量
          pageSize: 10, // 每页条数
          onChange(page) {
            // 点击分页条发生的事件
            getList(10, page);
          },
          showTotal: (total) => {
            return `共 ${total} 条`;
          },
        }}
        columns={[
          {
            title: "序号",
            render(v, r, i) {
              // 参数为当前单元格,当前单元行,当前索引
              return i + 1;
            },
          },
          {
            title: "标题",
            dataIndex: "title", // 这个是根据数组内对象的键进行绑定的
          },
          {
            title: "封面",
            render(v, r) {
              return (
                <Image
                  src={r.image}
                  width={80}
                  height={80}
                  alt={r.title}
                ></Image>
              );
            },
          },
          {
            title: "简介",
            dataIndex: "desc",
          },
          {
            title: "操作",
            render(v, r) {
              return (
                <Space>
                  <Button
                    type="primary"
                    size="small"
                    icon={<EditOutlined />}
                    onClick={() => {
                      setOpen(true);
                      setDataItemId(r.id); // 将模式改为修改模式
                      setImageUrl(r.image); // 设置回显的图片
                      // 回显数据(将当前单元格数据回显给from)
                      setTimeout(() => {
                        myForm.setFieldsValue(r); // 这一行代码就能回显数据了(由于每次都是销毁model和form,因此每次打开时加个异步确保能得到myForm)
                      }, 50);
                    }}
                  ></Button>
                  {/* 删除的弹窗提示 */}
                  <Popconfirm
                    title="是否确认删除"
                    onConfirm={() => {
                      // 删除文章
                      fetch("/api/admin/articles/" + r.id, {
                        method: "DELETE",
                        body: JSON.stringify(v),
                      })
                        .then((res) => res.json())
                        .then((res) => {
                          // 重新获取数据
                          getList(10, 1);
                        });
                    }}
                  >
                    <Button
                      type="primary"
                      danger
                      size="small"
                      icon={<DeleteOutlined />}
                    ></Button>
                  </Popconfirm>
                </Space>
              );
            },
          },
        ]}
      ></Table>
      <Modal
        title="编辑"
        open={open}
        onCancel={() => {
          setOpen(false);
          setImageUrl('') // 清空图片
        }}
        destroyOnClose={true} // 关闭窗口之后销毁(预防弹出时回显数据)
        maskClosable={false} // 点击空白区域不关闭弹窗
        onOk={() => {
          // 模态框点击确认触发表单提交事件
          myForm.submit();
        }}
      >
        <Form
          preserve={false} // 和model结合使用时加上这个,否则表单不会销毁
          layout="vertical"
          form={myForm}
          onFinish={(v) => {
            // 上面触发了表单提交后,这个表单的onFinish事件可以获取到表单内容
            // 这里就可以调用接口了
            console.log("v", v);
            if (dataItemId != "") {
              // 修改文章
              fetch("/api/admin/articles/" + dataItemId, {
                method: "PUT",
                body: JSON.stringify({ ...v, image: imageUrl }),
              })
                .then((res) => res.json())
                .then((res) => {
                  // 关闭模态框
                  setOpen(false);
                  // 重新获取数据
                  getList(10, 1);
                  setImageUrl('') // 清空图片
                });
            } else {
              // 新增文章
              fetch("/api/admin/articles", {
                method: "POST",
                body: JSON.stringify({ ...v, image: imageUrl }),
              })
                .then((res) => res.json())
                .then((res) => {
                  // 关闭模态框
                  setOpen(false);
                  // 重新获取数据
                  getList(10, 1);
                  setImageUrl('') // 清空图片
                });
            }
          }}
        >
          <Form.Item
            label="姓名"
            name="title"
            rules={[{ required: true, message: "标题不能为空" }]}
          >
            <Input placeholder="请输入姓名"></Input>
          </Form.Item>
          <Form.Item label="简介" name="desc">
            <Input.TextArea placeholder="请输入简介"></Input.TextArea>
          </Form.Item>
          <Form.Item label="封面">
            <MyUpload
              imageUrl={imageUrl}
              setImageUrl={(url) => {
                setImageUrl(url);
              }}
            ></MyUpload>
          </Form.Item>
        </Form>
      </Modal>
    </Card>
  );
}

效果图

引入富文本

富文本官方地址

安装富文本

js 复制代码
yarn add @wangeditor/editor
// 两个都要装
yarn add @wangeditor/editor-for-react

新建富文本组件,在app/admin/_components下新增MyEditor.js,内容如下(直接粘贴这个页面的代码和下面是一样的)

js 复制代码
'use client'
import '@wangeditor/editor/dist/css/style.css' // 引入 css

import React, { useState, useEffect } from 'react'
import { Editor, Toolbar } from '@wangeditor/editor-for-react'
import { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor'

function MyEditor({html,setHtml}) {
    // editor 实例
    const [editor, setEditor] = useState(null)                  

    // 工具栏配置
    const toolbarConfig = { }                        

    // 编辑器配置
    const editorConfig = {                         
        placeholder: '请输入内容...',
    }

    // 及时销毁 editor ,重要!
    useEffect(() => {
        return () => {
            if (editor == null) return
            editor.destroy()
            setEditor(null)
        }
    }, [editor])

    return (
        <>
            <div style={{ border: '1px solid #ccc', zIndex: 100}}>
                <Toolbar
                    editor={editor}
                    defaultConfig={toolbarConfig}
                    mode="default"
                    style={{ borderBottom: '1px solid #ccc' }}
                />
                <Editor
                    defaultConfig={editorConfig}
                    value={html}
                    onCreated={setEditor}
                    onChange={editor => setHtml(editor.getHtml())}
                    mode="default"
                    style={{ height: '500px', overflowY: 'hidden' }}
                />
            </div>
            {/* <div style={{ marginTop: '15px' }}>
                {html}
            </div> */}
        </>
    )
}

export default MyEditor

使用富文本组件(app/admin/(admin-layout)/articles/page.js)

js 复制代码
"use client";
import {
  Button,
  Card,
  Form,
  Input,
  Modal,
  Popconfirm,
  Space,
  Table,
} from "antd";
import {
  SearchOutlined,
  PlusOutlined,
  EditOutlined,
  DeleteOutlined,
} from "@ant-design/icons";
import { useEffect, useState } from "react";
import MyUpload from "../../_conponents/MyUpload";
import Image from "next/image";
import MyEditor from "../../_conponents/MyEditor";
export default function () {
  const [myForm] = Form.useForm(); // 获取Form组件
  const [open, setOpen] = useState(false); // 控制弹窗显示隐藏
  const [list, setList] = useState([]); // 数据
  const [dataItemId, setDataItemId] = useState(false); // 判断是新增还是修改,无id新增,有id是修改
  const [total, setTotal] = useState(0); // 总数量
  const [imageUrl, setImageUrl] = useState(""); // 上传得到的url图片路径
  // 编辑器内容
  const [html, setHtml] = useState("");
  function getList(a, b, c = "") {
    fetch(`/api/admin/articles?per=${a}&page=${b}&title=${c}`)
      .then((res) => res.json())
      .then((res) => {
        console.log("res", res);
        setList(res.data.list);
        setTotal(res.data.total);
      });
  }

  useEffect(() => {
    getList(10, 1); // 默认的分页
  }, []);

  return (
    <Card
      title={"文章管理"}
      extra={
        <>
          <Button
            onClick={() => {
              setOpen(true);
              setDataItemId(""); // 将模式改为新增模式
            }}
            icon={<PlusOutlined />}
            type="primary"
          />
        </>
      }
    >
      <Form
        layout="inline"
        onFinish={(v) => {
          console.log("v", v);
          getList(10, 1, v.title);
        }}
      >
        <Form.Item label="标题" name="title">
          <Input placeholder="请输入关键词"></Input>
        </Form.Item>
        <Form.Item>
          <Button
            htmlType="submit"
            icon={<SearchOutlined />}
            type="primary"
          ></Button>
        </Form.Item>
      </Form>
      <Table
        dataSource={list}
        rowKey={"id"}
        style={{ marginTop: "8px" }}
        pagination={{
          total, // 总数量
          pageSize: 10, // 每页条数
          onChange(page) {
            // 点击分页条发生的事件
            getList(10, page);
          },
          showTotal: (total) => {
            return `共 ${total} 条`;
          },
        }}
        columns={[
          {
            title: "序号",
            render(v, r, i) {
              // 参数为当前单元格,当前单元行,当前索引
              return i + 1;
            },
          },
          {
            title: "标题",
            dataIndex: "title", // 这个是根据数组内对象的键进行绑定的
          },
          {
            title: "封面",
            render(v, r) {
              return (
                <Image
                  src={r.image}
                  width={80}
                  height={80}
                  alt={r.title}
                ></Image>
              );
            },
          },
          {
            title: "简介",
            dataIndex: "desc",
          },
          {
            title: "操作",
            render(v, r) {
              return (
                <Space>
                  <Button
                    type="primary"
                    size="small"
                    icon={<EditOutlined />}
                    onClick={() => {
                      setOpen(true);
                      setDataItemId(r.id); // 将模式改为修改模式
                      setImageUrl(r.image); // 设置回显的图片
                      setHtml(r.content) // 设置富文本回显
                      // 回显数据(将当前单元格数据回显给from)
                      setTimeout(() => {
                        myForm.setFieldsValue(r); // 这一行代码就能回显数据了(由于每次都是销毁model和form,因此每次打开时加个异步确保能得到myForm)
                      }, 50);
                    }}
                  ></Button>
                  {/* 删除的弹窗提示 */}
                  <Popconfirm
                    title="是否确认删除"
                    onConfirm={() => {
                      // 删除文章
                      fetch("/api/admin/articles/" + r.id, {
                        method: "DELETE",
                        body: JSON.stringify(v),
                      })
                        .then((res) => res.json())
                        .then((res) => {
                          // 重新获取数据
                          getList(10, 1);
                        });
                    }}
                  >
                    <Button
                      type="primary"
                      danger
                      size="small"
                      icon={<DeleteOutlined />}
                    ></Button>
                  </Popconfirm>
                </Space>
              );
            },
          },
        ]}
      ></Table>
      <Modal
        title="编辑"
        open={open}
        width={"75vw"}
        onCancel={() => {
          setOpen(false);
          setImageUrl(""); // 清空图片
          setHtml("") // 清空富文本
        }}
        destroyOnClose={true} // 关闭窗口之后销毁(预防弹出时回显数据)
        maskClosable={false} // 点击空白区域不关闭弹窗
        onOk={() => {
          // 模态框点击确认触发表单提交事件
          myForm.submit();
        }}
      >
        <Form
          preserve={false} // 和model结合使用时加上这个,否则表单不会销毁
          layout="vertical"
          form={myForm}
          onFinish={(v) => {
            // 上面触发了表单提交后,这个表单的onFinish事件可以获取到表单内容
            // 这里就可以调用接口了
            console.log("v", v);
            if (dataItemId != "") {
              // 修改文章
              fetch("/api/admin/articles/" + dataItemId, {
                method: "PUT",
                // 下面多的两个一个是封面,一个是html内容
                body: JSON.stringify({ ...v, image: imageUrl,content:html }),
              })
                .then((res) => res.json())
                .then((res) => {
                  // 关闭模态框
                  setOpen(false);
                  // 重新获取数据
                  getList(10, 1);
                  setImageUrl(""); // 清空图片
                  setHtml("") // 清空富文本
                });
            } else {
              // 新增文章
              fetch("/api/admin/articles", {
                method: "POST",
                body: JSON.stringify({ ...v, image: imageUrl,content:html }),
              })
                .then((res) => res.json())
                .then((res) => {
                  // 关闭模态框
                  setOpen(false);
                  // 重新获取数据
                  getList(10, 1);
                  setImageUrl(""); // 清空图片
                  setHtml("") // 清空富文本
                });
            }
          }}
        >
          <Form.Item
            label="姓名"
            name="title"
            rules={[{ required: true, message: "标题不能为空" }]}
          >
            <Input placeholder="请输入姓名"></Input>
          </Form.Item>
          <Form.Item label="简介" name="desc">
            <Input.TextArea placeholder="请输入简介"></Input.TextArea>
          </Form.Item>
          <Form.Item label="封面">
            <MyUpload
              imageUrl={imageUrl}
              setImageUrl={(url) => {
                setImageUrl(url);
              }}
            ></MyUpload>
          </Form.Item>
          <Form label="详情">
            <MyEditor html={html} setHtml={setHtml}></MyEditor>
          </Form>
        </Form>
      </Modal>
    </Card>
  );
}

使用富文本组件的文件上传功能

图片上传官方文档

在app/api/common下面新建wang_editor/upload/route.js文件,这里用来编写富文本图片上传功能的接口 与上面文件上传基本类似,只更改了返回数据的格式,其他不变,可以直接复制粘贴过来

js 复制代码
import { NextRequest, NextResponse } from "next/server"; // 从 "next/server" 模块导入 NextRequest 和 NextResponse,用于处理请求和响应。
import dayjs from "dayjs"; // 导入 dayjs 库,用于日期格式化。
import path from "path"; // 导入 path 模块,用于处理文件路径。
import fs from "fs"; // 导入 fs 模块,用于文件系统操作。
import { randomUUID } from "crypto"; // 从 "crypto" 模块导入 randomUUID,用于生成随机 UUID。

// 定义一个异步函数,用于保存上传的文件
const saveFile = async (blob) => {
  const dirName = "/uploads/" + dayjs().format("YYYY-MM-DD"); // 根据当前日期生成存储目录路径
  const uploadDir = path.join(process.cwd(), "public" + dirName); // 生成完整的存储目录路径
  fs.mkdirSync(uploadDir, {
    recursive: true,
  }); // 如果存储目录不存在,则创建目录
  const fileName = randomUUID() + ".png"; // 生成唯一的文件名
  const arrayBuffer = await blob.arrayBuffer(); // 将文件 blob 转换为 ArrayBuffer
  fs.writeFileSync(uploadDir + "/" + fileName, new DataView(arrayBuffer)); // 将文件数据写入到指定路径
  return dirName + "/" + fileName; // 返回文件的存储路径
};

// 定义一个 POST 请求处理器
export const POST = async (req) => {
  const data = await req.formData(); // 从请求中提取表单数据
  const fileName = await saveFile(data.get('file')); // 保存上传的文件并获取文件路径
  return NextResponse.json({
    errno:0, // 注意:值是数字,不能是字符串
    data: {
        url: fileName
    }
  });
};

在app/admin/_components/MyEditor.js文件内加一个配置和接口地址即可,代码更改如下

js 复制代码
"use client";
import {
  Button,
  Card,
  Form,
  Input,
  Modal,
  Popconfirm,
  Space,
  Table,
} from "antd";
import {
  SearchOutlined,
  PlusOutlined,
  EditOutlined,
  DeleteOutlined,
} from "@ant-design/icons";
import { useEffect, useState } from "react";
import MyUpload from "../../_conponents/MyUpload";
import Image from "next/image";
// 下面这个代表只在客户端引入富文本编辑器,不在编译的时候做处理(因为编译的时候富文本这个组件编译报错)
import dynamic from "next/dynamic";
const MyEditor = dynamic(()=>import("../../_conponents/MyEditor"),{
  ssr:false 
});
export default function () {
  const [myForm] = Form.useForm(); // 获取Form组件
  const [open, setOpen] = useState(false); // 控制弹窗显示隐藏
  const [list, setList] = useState([]); // 数据
  const [dataItemId, setDataItemId] = useState(false); // 判断是新增还是修改,无id新增,有id是修改
  const [total, setTotal] = useState(0); // 总数量
  const [imageUrl, setImageUrl] = useState(""); // 上传得到的url图片路径
  // 编辑器内容
  const [html, setHtml] = useState("");
  function getList(a, b, c = "") {
    fetch(`/api/admin/articles?per=${a}&page=${b}&title=${c}`)
      .then((res) => res.json())
      .then((res) => {
        console.log("res", res);
        setList(res.data.list);
        setTotal(res.data.total);
      });
  }

  useEffect(() => {
    getList(10, 1); // 默认的分页
  }, []);

  return (
    <Card
      title={"文章管理"}
      extra={
        <>
          <Button
            onClick={() => {
              setOpen(true);
              setDataItemId(""); // 将模式改为新增模式
            }}
            icon={<PlusOutlined />}
            type="primary"
          />
        </>
      }
    >
      <Form
        layout="inline"
        onFinish={(v) => {
          console.log("v", v);
          getList(10, 1, v.title);
        }}
      >
        <Form.Item label="标题" name="title">
          <Input placeholder="请输入关键词"></Input>
        </Form.Item>
        <Form.Item>
          <Button
            htmlType="submit"
            icon={<SearchOutlined />}
            type="primary"
          ></Button>
        </Form.Item>
      </Form>
      <Table
        dataSource={list}
        rowKey={"id"}
        style={{ marginTop: "8px" }}
        pagination={{
          total, // 总数量
          pageSize: 10, // 每页条数
          onChange(page) {
            // 点击分页条发生的事件
            getList(10, page);
          },
          showTotal: (total) => {
            return `共 ${total} 条`;
          },
        }}
        columns={[
          {
            title: "序号",
            render(v, r, i) {
              // 参数为当前单元格,当前单元行,当前索引
              return i + 1;
            },
          },
          {
            title: "标题",
            dataIndex: "title", // 这个是根据数组内对象的键进行绑定的
          },
          {
            title: "封面",
            render(v, r) {
              return (
                <Image
                  src={r.image}
                  width={80}
                  height={80}
                  alt={r.title}
                ></Image>
              );
            },
          },
          {
            title: "简介",
            dataIndex: "desc",
          },
          {
            title: "操作",
            render(v, r) {
              return (
                <Space>
                  <Button
                    type="primary"
                    size="small"
                    icon={<EditOutlined />}
                    onClick={() => {
                      setOpen(true);
                      setDataItemId(r.id); // 将模式改为修改模式
                      setImageUrl(r.image); // 设置回显的图片
                      setHtml(r.content) // 设置富文本回显
                      // 回显数据(将当前单元格数据回显给from)
                      setTimeout(() => {
                        myForm.setFieldsValue(r); // 这一行代码就能回显数据了(由于每次都是销毁model和form,因此每次打开时加个异步确保能得到myForm)
                      }, 50);
                    }}
                  ></Button>
                  {/* 删除的弹窗提示 */}
                  <Popconfirm
                    title="是否确认删除"
                    onConfirm={() => {
                      // 删除文章
                      fetch("/api/admin/articles/" + r.id, {
                        method: "DELETE",
                        body: JSON.stringify(v),
                      })
                        .then((res) => res.json())
                        .then((res) => {
                          // 重新获取数据
                          getList(10, 1);
                        });
                    }}
                  >
                    <Button
                      type="primary"
                      danger
                      size="small"
                      icon={<DeleteOutlined />}
                    ></Button>
                  </Popconfirm>
                </Space>
              );
            },
          },
        ]}
      ></Table>
      <Modal
        title="编辑"
        open={open}
        width={"75vw"}
        onCancel={() => {
          setOpen(false);
          setImageUrl(""); // 清空图片
          setHtml("") // 清空富文本
        }}
        destroyOnClose={true} // 关闭窗口之后销毁(预防弹出时回显数据)
        maskClosable={false} // 点击空白区域不关闭弹窗
        onOk={() => {
          // 模态框点击确认触发表单提交事件
          myForm.submit();
        }}
      >
        <Form
          preserve={false} // 和model结合使用时加上这个,否则表单不会销毁
          layout="vertical"
          form={myForm}
          onFinish={(v) => {
            // 上面触发了表单提交后,这个表单的onFinish事件可以获取到表单内容
            // 这里就可以调用接口了
            console.log("v", v);
            if (dataItemId != "") {
              // 修改文章
              fetch("/api/admin/articles/" + dataItemId, {
                method: "PUT",
                // 下面多的两个一个是封面,一个是html内容
                body: JSON.stringify({ ...v, image: imageUrl,content:html }),
              })
                .then((res) => res.json())
                .then((res) => {
                  // 关闭模态框
                  setOpen(false);
                  // 重新获取数据
                  getList(10, 1);
                  setImageUrl(""); // 清空图片
                  setHtml("") // 清空富文本
                });
            } else {
              // 新增文章
              fetch("/api/admin/articles", {
                method: "POST",
                body: JSON.stringify({ ...v, image: imageUrl,content:html }),
              })
                .then((res) => res.json())
                .then((res) => {
                  // 关闭模态框
                  setOpen(false);
                  // 重新获取数据
                  getList(10, 1);
                  setImageUrl(""); // 清空图片
                  setHtml("") // 清空富文本
                });
            }
          }}
        >
          <Form.Item
            label="姓名"
            name="title"
            rules={[{ required: true, message: "标题不能为空" }]}
          >
            <Input placeholder="请输入姓名"></Input>
          </Form.Item>
          <Form.Item label="简介" name="desc">
            <Input.TextArea placeholder="请输入简介"></Input.TextArea>
          </Form.Item>
          <Form.Item label="封面">
            <MyUpload
              imageUrl={imageUrl}
              setImageUrl={(url) => {
                setImageUrl(url);
              }}
            ></MyUpload>
          </Form.Item>
          <Form label="详情">
            <MyEditor html={html} setHtml={setHtml}></MyEditor>
          </Form>
        </Form>
      </Modal>
    </Card>
  );
}

然后富文本就可以进行图片上传了

打包

运行命令

js 复制代码
yarn add build

运行打包的资源

js 复制代码
yarn add start

到此,nextjs项目阶段就结束了

相关推荐
程序员的世界你不懂24 分钟前
【Flask】测试平台开发,新增说明书编写和展示功能 第二十三篇
java·前端·数据库
索迪迈科技26 分钟前
网络请求库——Axios库深度解析
前端·网络·vue.js·北京百思可瑞教育·百思可瑞教育
gnip34 分钟前
JavaScript二叉树相关概念
前端
attitude.x1 小时前
PyTorch 动态图的灵活性与实用技巧
前端·人工智能·深度学习
β添砖java2 小时前
CSS3核心技术
前端·css·css3
空山新雨(大队长)2 小时前
HTML第八课:HTML4和HTML5的区别
前端·html·html5
猫头虎-前端技术2 小时前
浏览器兼容性问题全解:CSS 前缀、Grid/Flex 布局兼容方案与跨浏览器调试技巧
前端·css·node.js·bootstrap·ecmascript·css3·媒体
阿珊和她的猫2 小时前
探索 CSS 过渡:打造流畅网页交互体验
前端·css
元亓亓亓2 小时前
JavaWeb--day1--HTML&CSS
前端·css·html