当前位置: 首页 > news >正文

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

创建项目

运行如下命令

npx create-next-app next-create

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

整理项目

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

页面与布局

将app/Layout.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存入以下内容

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,内容如下

// 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内容如下

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

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

export default function userLayout({ children }) {return (<section>marketing下面的layout{children}</section>);
}
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

'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里面代码如下

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组件

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

2.修改app/Layout.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效果如果要出来的话,页面数据必须要是有异步效果,因为我没注意到这点,费了点时间才搞明白

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的样式仅仅作用于当前文件夹下的所有页面

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,放入以下内容

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

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

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

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

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

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

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

在这里插入图片描述
当我们访问其他页面有错误信息时,但是没有给那个页面单独定义错误样式,则会触发全局的错误样式

例如访问: localhost:3000/about

在这里插入图片描述

链接和导航

修改下app/page.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></>);
}

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

<Link href="/dashboard#settings">Settings</Link>// Output
<a href="/dashboard#settings">Settings</a>

平行路由(Parallel Routes)

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

在app/Layout.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,放入以下内容

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文件,插入以下内容

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里面进行配置)

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

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

在这里插入图片描述

定义404页面

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

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

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

在这里插入图片描述

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

静态设置meta标签

在任意page.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加入以下代码

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一样是约定名称,固定的

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文件,放入以下内容

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写客户端组件进行发请求,代码如下:
"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里面引入组件
import List from "./_components/list";export default function(){return <List></List>
}
  1. 访问http://localhost:3000/list,效果图如下

在这里插入图片描述

数据库引入

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

1.安装库

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

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

npx prisma init --datasource-provider sqlite

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

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schemagenerator client {provider = "prisma-client-js"
}datasource db {provider = "sqlite"url      = env("DATABASE_URL")
}// 新增如下
model Goods{id String @id @unique @default(uuid())name Stringdesc String @default("")content String @default("") createAt DateTime @default(now()) @map("create_at") // @map起别名,默认当前时间updateAt DateTime @updatedAt @map("update_at")@@map("products") // 表名
}

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

npx prisma db push

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

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

在这里插入图片描述

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

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

import { PrismaClient } from '@prisma/client'const prismaClientSingleton = () => {return new PrismaClient()
}const prisma = globalThis.prismaGlobal ?? prismaClientSingleton()export default prismaif (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma

8.将刚刚的/api/good/route.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组件库

官方网站
安装

yarn add antd

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

import React from 'react';
import { DatePicker } from 'antd';const App = () => {return <DatePicker />;
};export default App;

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

在这里插入图片描述
这样就代表组件库已经引入进来了

但是如果发现样式和antd样式不一致,就是tailwindcss和其样式冲突了,我们需要在tailwind.config.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直接复制粘贴来的)

"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" /><Menutheme="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><Headerstyle={{padding: 0,background: colorBgContainer,}}><Buttontype="text"icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}onClick={() => setCollapsed(!collapsed)}style={{fontSize: "16px",width: 64,height: 64,}}/></Header><Contentstyle={{margin: "24px 16px",padding: 24,minHeight: 280,background: colorBgContainer,borderRadius: borderRadiusLG,}}>{children}</Content></Layout></Layout></>);
}

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

import AntdAdmin from "../_conponents/AntdAdmin";export default function({children}){return <AntdAdmin>{children}</AntdAdmin>
}

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

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

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

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

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

"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 关键词下拉找到这段代码,下面的是改造后的):

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')){// 不是登录页面的话,判断是否登陆过,这里取cookieif(request.cookies.get('admin-token')){// 如果有cookie,代表已经登陆了,什么都不做}else{// 未登录,跳转到登录页面return NextResponse.redirect(new URL('/admin/login',request.url))}}}
}

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

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

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不会成功跳转的

"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,内容如下

'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如下

"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" /><Menutheme="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><Headerstyle={{padding: 0,background: colorBgContainer,}}><Buttontype="text"icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}onClick={() => setCollapsed(!collapsed)}style={{fontSize: "16px",width: 64,height: 64,}}/></Header><Contentstyle={{margin: "24px 16px",padding: 24,minHeight: 280,background: colorBgContainer,borderRadius: borderRadiusLG,}}>{children}</Content></Layout></Layout></>);
}

效果图

在这里插入图片描述
在这里插入图片描述

文章列表和新增文章实现

在(admin-layout)文件夹下面新增articles/page.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新增如下模型

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schemagenerator client {provider = "prisma-client-js"
}datasource db {provider = "sqlite"url      = env("DATABASE_URL")
}model Goods{id String @id @unique @default(uuid())name Stringdesc 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 Stringdesc String? @default("")  // ?代表是可选参数content String? @default("") image String? @default("") // 文章封面createAt DateTime @default(now()) @map("create_at") // @map起别名,默认当前时间updateAt DateTime @updatedAt @map("update_at")@@map("article") // 表名
}

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

npx prisma db push

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

查询和新增数据

在app/api/admin下面新增articles/route.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参数的,因此这里用了动态路由)

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文件,放入以下内容

"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 (<Cardtitle={"文章管理"}extra={<><ButtononClick={() => {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><TabledataSource={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><Buttontype="primary"size="small"icon={<EditOutlined />}onClick={() => {setOpen(true);setDataItemId(r.id); // 将模式改为修改模式// 回显数据(将当前单元格数据回显给from)setTimeout(() => {myForm.setFieldsValue(r); // 这一行代码就能回显数据了(由于每次都是销毁model和form,因此每次打开时加个异步确保能得到myForm)}, 50);}}></Button>{/* 删除的弹窗提示 */}<Popconfirmtitle="是否确认删除"onConfirm={() => {// 删除文章fetch("/api/admin/articles/" + r.id, {method: "DELETE",body: JSON.stringify(v),}).then((res) => res.json()).then((res) => {// 重新获取数据getList();});}}><Buttontype="primary"dangersize="small"icon={<DeleteOutlined />}></Button></Popconfirm></Space>);},},]}></Table><Modaltitle="编辑"open={open}onCancel={() => setOpen(false)}destroyOnClose={true} // 关闭窗口之后销毁(预防弹出时回显数据)maskClosable={false} // 点击空白区域不关闭弹窗onOk={() => {// 模态框点击确认触发表单提交事件myForm.submit();}}><Formpreserve={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.Itemlabel="姓名"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文件,修改如下(就是修改下查询接口,添加一下分页查询)

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; // 从参数取,取不到则取10const 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文件,加入分页查询功能的接口(内容如下)

"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 (<Cardtitle={"文章管理"}extra={<><ButtononClick={() => {setOpen(true);setDataItemId(""); // 将模式改为新增模式}}icon={<PlusOutlined />}type="primary"/></>}><Formlayout="inline"onFinish={(v) => {console.log("v", v);getList(10, 1, v.title);}}><Form.Item label="标题" name="title"><Input placeholder="请输入关键词"></Input></Form.Item><Form.Item><ButtonhtmlType="submit"icon={<SearchOutlined />}type="primary"></Button></Form.Item></Form><TabledataSource={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><Buttontype="primary"size="small"icon={<EditOutlined />}onClick={() => {setOpen(true);setDataItemId(r.id); // 将模式改为修改模式// 回显数据(将当前单元格数据回显给from)setTimeout(() => {myForm.setFieldsValue(r); // 这一行代码就能回显数据了(由于每次都是销毁model和form,因此每次打开时加个异步确保能得到myForm)}, 50);}}></Button>{/* 删除的弹窗提示 */}<Popconfirmtitle="是否确认删除"onConfirm={() => {// 删除文章fetch("/api/admin/articles/" + r.id, {method: "DELETE",body: JSON.stringify(v),}).then((res) => res.json()).then((res) => {// 重新获取数据getList();});}}><Buttontype="primary"dangersize="small"icon={<DeleteOutlined />}></Button></Popconfirm></Space>);},},]}></Table><Modaltitle="编辑"open={open}onCancel={() => setOpen(false)}destroyOnClose={true} // 关闭窗口之后销毁(预防弹出时回显数据)maskClosable={false} // 点击空白区域不关闭弹窗onOk={() => {// 模态框点击确认触发表单提交事件myForm.submit();}}><Formpreserve={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.Itemlabel="姓名"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文件,内容如下:

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 转换为 ArrayBufferfs.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组件代码)

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 = (<buttonstyle={{border: 0,background: "none",}}type="button">{loading ? <LoadingOutlined /> : <PlusOutlined />}<divstyle={{marginTop: 8,}}>Upload</div></button>);return (<><Uploadname="file" // 3.文件类型listType="picture-card"className="avatar-uploader"showUploadList={false}action="/api/common/upload" // 4.接口地址beforeUpload={beforeUpload}onChange={handleChange}>{imageUrl ? (<imgsrc={imageUrl}alt="avatar"style={{width: "100%",}}/>) : (uploadButton)}</Upload></>);
}

增加图片上传页面,修改文章管理页面代码(app/admin/(admin-layout)/articles/page.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 (<Cardtitle={"文章管理"}extra={<><ButtononClick={() => {setOpen(true);setDataItemId(""); // 将模式改为新增模式}}icon={<PlusOutlined />}type="primary"/></>}><Formlayout="inline"onFinish={(v) => {console.log("v", v);getList(10, 1, v.title);}}><Form.Item label="标题" name="title"><Input placeholder="请输入关键词"></Input></Form.Item><Form.Item><ButtonhtmlType="submit"icon={<SearchOutlined />}type="primary"></Button></Form.Item></Form><TabledataSource={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 (<Imagesrc={r.image}width={80}height={80}alt={r.title}></Image>);},},{title: "简介",dataIndex: "desc",},{title: "操作",render(v, r) {return (<Space><Buttontype="primary"size="small"icon={<EditOutlined />}onClick={() => {setOpen(true);setDataItemId(r.id); // 将模式改为修改模式setImageUrl(r.image); // 设置回显的图片// 回显数据(将当前单元格数据回显给from)setTimeout(() => {myForm.setFieldsValue(r); // 这一行代码就能回显数据了(由于每次都是销毁model和form,因此每次打开时加个异步确保能得到myForm)}, 50);}}></Button>{/* 删除的弹窗提示 */}<Popconfirmtitle="是否确认删除"onConfirm={() => {// 删除文章fetch("/api/admin/articles/" + r.id, {method: "DELETE",body: JSON.stringify(v),}).then((res) => res.json()).then((res) => {// 重新获取数据getList(10, 1);});}}><Buttontype="primary"dangersize="small"icon={<DeleteOutlined />}></Button></Popconfirm></Space>);},},]}></Table><Modaltitle="编辑"open={open}onCancel={() => {setOpen(false);setImageUrl('') // 清空图片}}destroyOnClose={true} // 关闭窗口之后销毁(预防弹出时回显数据)maskClosable={false} // 点击空白区域不关闭弹窗onOk={() => {// 模态框点击确认触发表单提交事件myForm.submit();}}><Formpreserve={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.Itemlabel="姓名"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="封面"><MyUploadimageUrl={imageUrl}setImageUrl={(url) => {setImageUrl(url);}}></MyUpload></Form.Item></Form></Modal></Card>);
}

效果图
在这里插入图片描述

引入富文本

富文本官方地址

安装富文本

yarn add @wangeditor/editor
// 两个都要装
yarn add @wangeditor/editor-for-react

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

'use client'
import '@wangeditor/editor/dist/css/style.css' // 引入 cssimport 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) returneditor.destroy()setEditor(null)}}, [editor])return (<><div style={{ border: '1px solid #ccc', zIndex: 100}}><Toolbareditor={editor}defaultConfig={toolbarConfig}mode="default"style={{ borderBottom: '1px solid #ccc' }}/><EditordefaultConfig={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)

"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 (<Cardtitle={"文章管理"}extra={<><ButtononClick={() => {setOpen(true);setDataItemId(""); // 将模式改为新增模式}}icon={<PlusOutlined />}type="primary"/></>}><Formlayout="inline"onFinish={(v) => {console.log("v", v);getList(10, 1, v.title);}}><Form.Item label="标题" name="title"><Input placeholder="请输入关键词"></Input></Form.Item><Form.Item><ButtonhtmlType="submit"icon={<SearchOutlined />}type="primary"></Button></Form.Item></Form><TabledataSource={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 (<Imagesrc={r.image}width={80}height={80}alt={r.title}></Image>);},},{title: "简介",dataIndex: "desc",},{title: "操作",render(v, r) {return (<Space><Buttontype="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>{/* 删除的弹窗提示 */}<Popconfirmtitle="是否确认删除"onConfirm={() => {// 删除文章fetch("/api/admin/articles/" + r.id, {method: "DELETE",body: JSON.stringify(v),}).then((res) => res.json()).then((res) => {// 重新获取数据getList(10, 1);});}}><Buttontype="primary"dangersize="small"icon={<DeleteOutlined />}></Button></Popconfirm></Space>);},},]}></Table><Modaltitle="编辑"open={open}width={"75vw"}onCancel={() => {setOpen(false);setImageUrl(""); // 清空图片setHtml("") // 清空富文本}}destroyOnClose={true} // 关闭窗口之后销毁(预防弹出时回显数据)maskClosable={false} // 点击空白区域不关闭弹窗onOk={() => {// 模态框点击确认触发表单提交事件myForm.submit();}}><Formpreserve={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.Itemlabel="姓名"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="封面"><MyUploadimageUrl={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文件,这里用来编写富文本图片上传功能的接口
与上面文件上传基本类似,只更改了返回数据的格式,其他不变,可以直接复制粘贴过来

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 转换为 ArrayBufferfs.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文件内加一个配置和接口地址即可,代码更改如下

"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 (<Cardtitle={"文章管理"}extra={<><ButtononClick={() => {setOpen(true);setDataItemId(""); // 将模式改为新增模式}}icon={<PlusOutlined />}type="primary"/></>}><Formlayout="inline"onFinish={(v) => {console.log("v", v);getList(10, 1, v.title);}}><Form.Item label="标题" name="title"><Input placeholder="请输入关键词"></Input></Form.Item><Form.Item><ButtonhtmlType="submit"icon={<SearchOutlined />}type="primary"></Button></Form.Item></Form><TabledataSource={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 (<Imagesrc={r.image}width={80}height={80}alt={r.title}></Image>);},},{title: "简介",dataIndex: "desc",},{title: "操作",render(v, r) {return (<Space><Buttontype="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>{/* 删除的弹窗提示 */}<Popconfirmtitle="是否确认删除"onConfirm={() => {// 删除文章fetch("/api/admin/articles/" + r.id, {method: "DELETE",body: JSON.stringify(v),}).then((res) => res.json()).then((res) => {// 重新获取数据getList(10, 1);});}}><Buttontype="primary"dangersize="small"icon={<DeleteOutlined />}></Button></Popconfirm></Space>);},},]}></Table><Modaltitle="编辑"open={open}width={"75vw"}onCancel={() => {setOpen(false);setImageUrl(""); // 清空图片setHtml("") // 清空富文本}}destroyOnClose={true} // 关闭窗口之后销毁(预防弹出时回显数据)maskClosable={false} // 点击空白区域不关闭弹窗onOk={() => {// 模态框点击确认触发表单提交事件myForm.submit();}}><Formpreserve={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.Itemlabel="姓名"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="封面"><MyUploadimageUrl={imageUrl}setImageUrl={(url) => {setImageUrl(url);}}></MyUpload></Form.Item><Form label="详情"><MyEditor html={html} setHtml={setHtml}></MyEditor></Form></Form></Modal></Card>);
}

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

打包

运行命令

yarn add build

运行打包的资源

yarn add start

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

相关文章:

  • java写一个验证码
  • 探索未来通信的新边界:AQChat一款融合AI的在线匿名聊天
  • 【网络编程开发】7.TCP可靠传输的原理
  • 解决CentOS 7无法识别ntfs的问题
  • 容器:现代计算的基础设施
  • 【LeetCode刷题】前缀和解决问题:560.和为k的子数组
  • 计算机二级Access选择题考点
  • openGauss学习笔记-300 openGauss AI特性-AI4DB数据库自治运维-DBMind的AI子功能-SQL Rewriter SQL语句改写
  • 使用超声波麦克风阵列预测数控机床刀具磨损
  • QUIC 和 TCP: 深入解析为什么 QUIC 更胜一筹
  • Spark学习——不同模式下执行脚本
  • 机器学习与数据挖掘知识点总结(二)分类算法
  • 如何翻译和本地化游戏?翻译访谈
  • 低功耗蓝牙ble开发(一)——bluez介绍及源码分析
  • 【C语言】递归复杂度与链表OJ之双指针
  • 【5+】跨webview多页面 触发事件(二)
  • ES6, React, Redux, Webpack写的一个爬 GitHub 的网页
  • exif信息对照
  • Java Agent 学习笔记
  • JavaScript HTML DOM
  • java概述
  • JS函数式编程 数组部分风格 ES6版
  • 案例分享〡三拾众筹持续交付开发流程支撑创新业务
  • 第13期 DApp 榜单 :来,吃我这波安利
  • 开源SQL-on-Hadoop系统一览
  • 利用jquery编写加法运算验证码
  • 目录与文件属性:编写ls
  • 听说你叫Java(二)–Servlet请求
  • 用quicker-worker.js轻松跑一个大数据遍历
  • 再次简单明了总结flex布局,一看就懂...
  • 说说我为什么看好Spring Cloud Alibaba
  • ​flutter 代码混淆
  • ​业务双活的数据切换思路设计(下)
  • ​一文看懂数据清洗:缺失值、异常值和重复值的处理
  • # Redis 入门到精通(七)-- redis 删除策略
  • #git 撤消对文件的更改
  • #图像处理
  • (2)(2.4) TerraRanger Tower/Tower EVO(360度)
  • (3) cmake编译多个cpp文件
  • (4)事件处理——(7)简单事件(Simple events)
  • (二)springcloud实战之config配置中心
  • (附源码)springboot家庭装修管理系统 毕业设计 613205
  • (附源码)ssm教材管理系统 毕业设计 011229
  • (亲测成功)在centos7.5上安装kvm,通过VNC远程连接并创建多台ubuntu虚拟机(ubuntu server版本)...
  • (十三)Flask之特殊装饰器详解
  • (一)【Jmeter】JDK及Jmeter的安装部署及简单配置
  • (自用)learnOpenGL学习总结-高级OpenGL-抗锯齿
  • *(长期更新)软考网络工程师学习笔记——Section 22 无线局域网
  • ***监测系统的构建(chkrootkit )
  • .apk 成为历史!
  • .mkp勒索病毒解密方法|勒索病毒解决|勒索病毒恢复|数据库修复
  • .NET WebClient 类下载部分文件会错误?可能是解压缩的锅
  • .NET/C# 项目如何优雅地设置条件编译符号?
  • .Net中的集合
  • .NET中使用Redis (二)