在我的上一篇文章中,带领大家用VERCEL
+NEXTJS
+PRISMA
完成了项目的基建工作,相信实践过的同学已经成功发布了属于自己的站点,并对这套开发部署流程有了一定的认知。
这次我们将继续使用PRISMA
来完成项目的基本业务操作CRUD
,那么我们开始吧!
相关技术
1.next-js 使用react构建全栈应用的框架
2.next-auth 针对 Next.js 应用程序的一个完整的开源身份验证解决方案
3.prisma 次世代 Node.js 和 TypeScript ORM
4.vercel 为开发人员提供构建更快、更个性化 Web 的框架、工作流和基础设施。
5.vercel-postgres 无服务器的 SQL 数据库,旨在与 Vercel 函数和前端框架集成。
6.tailwindcss 一个效用优先的 CSS 框架
制定需求
本篇文章我们就拿大家最熟悉的博客系统来练习,抛砖引玉,你熟悉了PRISMA
基本的CRUD
操作后可以将其实践于任何你想实现的业务上
- 创建文章
CREATE
- 发布文章
UPDATE
- 展示文章
READ
- 删除文章
DELETE
修改表结构
在./prisma/schema.prisma
文件中修改表结构如下
model Post {
id String @id @default(cuid())
title String //文章标题
content String? //文章内容
published Boolean @default(false) //是否发布
author User? @relation(fields: [authorId], references: [id]) //文章作者
authorId String? //作者id
createdAt DateTime @default(now()) @map(name: "created_at") //文章创建时间
}
model User {
id String @id @default(cuid())
email String @unique //用户邮箱
nickname String //用户昵称
password String //用户密码
posts Post[] //用户关联的文章
createdAt DateTime @default(now()) @map(name: "created_at") //用户创建时间
}
这里我们添加了一张存储文章的
Post
表 ,可以很清楚的看到在author User? @relation(fields: [authorId], references: [id])
这行我们通过authorId
关联上了User
表中的id
属性,prisma
会自动帮我们完成映射操作
完成了表结构的修改后我们启动prisma studio
npx prisma studio
推送数据库更改
npx prisma db push
此时我们打开http://localhost:5555/
会看到User
表和Post
表结构已经更新
修改注册流程
由于我们需要展示每篇文章的作者信息,所以在注册时我们要有用户昵称的提交逻辑
在./app./auth/register/route.ts
中添加nickname
字段的注入逻辑
...
export async function POST(req: Request) {
const { email, password, nickname } = await req.json();
const exists = await prisma.user.findUnique({
where: {
email,
},
});
if (exists) {
return NextResponse.json({ error: "User already exists" }, { status: 400 });
} else {
const user = await prisma.user.create({
data: {
nickname,//用户昵称
email,
password: await hash(password, 10),
},
});
return NextResponse.json(user);
}
}
修改./components/form.tsx
文件如下
...
export default function Form({ type }: { type: "login" | "register" }) {
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleRegister = (e) => {
e.preventDefault();
setLoading(true);
if (type === "login") {
//登录逻辑
} else {
fetch("/api/auth/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: e.currentTarget.email.value,
password: e.currentTarget.password.value,
nickname: e.currentTarget.nickname.value, //注入用户昵称
}),
}).then(async (res) => {
setLoading(false);
if (res.status === 200) {
toast.success("Account created! Redirecting to login...");
setTimeout(() => {
router.push("/login");
}, 2000);
} else {
const { error } = await res.json();
toast.error(error);
}
});
}
};
return (
<form
onSubmit={}
className="flex flex-col space-y-4 bg-gray-50 px-4 py-8 sm:px-16"
>
// 添加昵称输入框
{type != "login" && (
<div>
<label
htmlFor="nickname"
className="block text-xs text-gray-600 uppercase"
>
nickname
</label>
<input
id="nickname"
name="nickname"
type="nickname"
placeholder="mario"
autoComplete="nickname"
required
/>
</div>
)}
</form>
);
}
修改后页面效果如下
文章创建API
创建./app/api/post/route.ts
文件,我们将在此文件中编写文章创建的接口
键入以下代码
import prisma from "@/lib/prisma";
import { getServerSession } from "next-auth/next";
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const { title, content } = await req.json();
const session = await getServerSession({ req });
if (session) {
const result = await prisma.post.create({
data: {
title: title,
content: content,
author: { connect: { email: session?.user?.email } },
},
});
return NextResponse.json(result);
} else {
return {
status: 401,
message: "Unauthorized",
};
}
}
可以看到我们声明了一个POST
方法,在获取到客户端传来的title
和content
参数后,调用getServerSession
来获取用户信息,拿到当前用户邮箱
随后调用post
表下prisma
为我们封装好的create
方法,将获取到的数据注入表中,并与当前用户邮箱关联
如果没有获取到session
则直接抛出401 Unauthorized
文章创建UI
由于表单提交需要与用户进行一些交互,react-hook
只能在客户端组件
中使用,所以我们针对文章创建的表单新增./components/postForm.tsx
文件,并完成基本的表单提交操作
"use client";
import { useState } from "react";
import toast from "react-hot-toast";
import { useRouter } from "next/navigation";
import LoadingDots from "@/components/loading-dots";
export default function PostForm() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const submitData = async (e: React.SyntheticEvent) => {
e.preventDefault();
setLoading(true);
try {
const body = { title, content };
// 调用刚刚编写的文章创建接口
await fetch(`/api/post`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).then(async (res) => {
await toast.success("success 🎉");
router.refresh();
setLoading(false);
});
} catch (error) {
setLoading(false);
console.error(error);
}
};
return (
<div>
<form onSubmit={submitData} className="mt-10">
<h1>NEW POST</h1>
<div className="bg-black h-px my-5"></div>
<h1>TITLE</h1>
<input
required
className="block bg-transparent outline-none my-5"
autoFocus
onChange={(e) => setTitle(e.target.value)}
placeholder="please type your title"
type="text"
value={title}
/>
<div className="bg-black h-px my-5"></div>
<h1>CONTENT</h1>
<textarea
required
className="block bg-transparent outline-none"
cols={50}
onChange={(e) => setContent(e.target.value)}
placeholder="please type your content"
rows={8}
value={content}
/>
<div className="bg-black h-px my-5"></div>
<button
className={` rounded-md px-10 py-2 text-sm font-semibold shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 ${
loading
? "cursor-not-allowed border-gray-200 bg-gray-100 text-black"
: "cursor-pointer bg-indigo-600 text-white hover:bg-indigo-500"
}`}
type="submit"
>
{loading ? <LoadingDots color="#808080" /> : "CREATE"}
</button>
<a
className="cursor-pointer ml-1 rounded-md bg-gray-600 px-10 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-gray-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
href="/"
>
CANCEL
</a>
</form>
</div>
);
}
还记得上一篇中我们需要登录后才能访问的./app/protected/page.tsx
页面吗,直接用这个页面来实现我们的文章创建页面,将./components/postForm.tsx
引入此页面即可,实现效果如下
创建第一篇文章,成功!🎉
由于我们还没有文章的展示页,所以到数据库中去验证一下
至此,我们已经完成了文章的创建工作,接下来让我们一鼓作气把剩下的RUD
都搞定吧💪
文章发布/删除API
创建./app/api/publish/[id]
和./app/api/delete/[id]
两个文件夹
,并分别在这两个文件夹中创建route.ts
注意
[id]
是文件夹的名称,使用过umi-js的同学应该不会陌生,这是约定式动态路由
的写法,想要了解更多请参考官方文档相关部分,next-js
中用这种方式所创建的路由将会匹配符合/api/publish/[id]
或/api/delete/[id]
格式的所有请求
项目结构如下
分别键入以下代码
//./app/api/publish/[id]/routes.ts
import prisma from "@/lib/prisma";
import { getServerSession } from "next-auth/next";
import { NextResponse } from "next/server";
import type { NextApiRequest } from "next";
export async function PUT(req: NextApiRequest) {
const list = req.url.split("/");
const postId = list[list.length - 1];
const session = await getServerSession({ req });
if (session) {
const post = await prisma.post.update({
where: { id: String(postId) },
data: { published: true },
});
return NextResponse.json(post);
} else {
return {
status: 401,
message: "Unauthorized",
};
}
}
//./app/api/delete/[id]/routes.ts
import prisma from "@/lib/prisma";
import { getServerSession } from "next-auth/next";
import { NextResponse } from "next/server";
import type { NextApiRequest } from "next";
export async function DELETE(req: NextApiRequest) {
const list = req.url.split("/");
const postId = list[list.length - 1];
const session = await getServerSession({ req });
if (session) {
const post = await prisma.post.delete({
where: { id: String(postId) },
});
return NextResponse.json(post);
} else {
return {
status: 401,
message: "Unauthorized",
};
}
}
我们从请求体中获取到id
、验证session
后调用update
和delete
方法对文章进行发布和删除操作
文章发布/删除UI
如果要发布/删除文章,就要先获取当前登录用户所关联的文章,我们可以很方便的在./app/protected/page.tsx
这个服务端页面
中直接调取prisma
的查询方法,而不用像在客户端组件
中以调用服务端接口的方式获取数据
在./app/protected/page.tsx
中添加以下代码
...
export default async function Protected() {
const session = await getServerSession();
const posts = await prisma.post.findMany({
where: {
author: {
email: session.user?.email,
},
},
include: {
author: {
select: { email: true, nickname: true },
},
},
});
return (
<div className="relative z-100 p-5">
<div className="w-min mx-auto ">
<ul role="list" className="divide-y divide-black-400">
{posts?.map((post) => (
<li key={post.id} className="flex justify-between gap-x-6 py-5">
<div className="flex gap-x-4">
<div className="min-w-0 flex-auto">
<p className="text-sm font-semibold leading-6 text-gray-900">
{post.title}
</p>
<p className="mt-1 truncate text-xs leading-5 text-gray-500 text-ellipsis w-80">
{post.content}
</p>
</div>
</div>
<div className="hidden sm:flex sm:flex-col sm:items-end">
<p className="text-sm leading-6 text-gray-900">
@{post?.author?.nickname}
</p>
{post.published ? (
<div className="mt-1 flex items-center gap-x-1.5 ">
<div className="flex-none rounded-full bg-emerald-500/20 p-1">
<div className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
</div>
<p className="text-xs leading-5 text-gray-500 whitespace-nowrap">
已发布 / {<DeleteBtn id={post.id} />}
</p>
</div>
) : (
<div className="mt-1 flex items-center gap-x-1.5">
<div className="flex-none rounded-full bg-gray-500/20 p-1">
<div className="h-1.5 w-1.5 rounded-full bg-gray-500" />
</div>
<p className="text-xs leading-5 text-gray-500 whitespace-nowrap">
未发布 / {<PublishBtn id={post.id} />}
</p>
</div>
)}
</div>
</li>
))}
</ul>
<PostForm />
</div>
</div>
);
}
我们先拿到session
中的email
值后,将其作为查询参数来调用prisma.post.findMany
方法,查询出当前登录用户所关联的文章列表,在include
中我们从所查到的原始数据中pick
出了文章作者的email
和nickname
字段,使其可以在response
中返回
接下来我们分别添加./components/deleteBtn.tsx
和./components/publishBtn.tsx
两个客户端组件并键入如下代码
// deleteBtn.tsx
"use client";
import { useRouter } from "next/navigation";
import LoadingDots from "@/components/loading-dots";
import { useState } from "react";
import toast from "react-hot-toast";
export default function DeleteBtn({ id }: { id: string }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const deletePost = async (): Promise<void> => {
setLoading(true);
await fetch(`/api/delete/${id}`, {
method: "DELETE",
});
await toast.success("Deleted successfully 🎉")
router.refresh();
};
return (
<span
onClick={() => {
if (loading) return;
toast((t) => (
<div>
<div>Are you sure you want to delete?</div>
<div className="flex justify-center mt-2">
<button
className="bg-indigo-400 text-white py-1 rounded-md text-sm w-20"
onClick={() => {
toast.dismiss(t.id);
deletePost();
}}
>
Yes
</button>
<button
className="bg-gray-400 text-white py-1 rounded-md ml-1 text-sm w-20"
onClick={() => {
toast.dismiss(t.id);
}}
>
No
</button>
</div>
</div>
));
}}
className="cursor-pointer text-pink-300 hover:text-pink-400"
>
{loading ? <LoadingDots color="#808080" /> : "DELETE"}
</span>
);
}
// publishBtn.tsx
//大部分代码与删除按钮一致
...
export default function PublishBtn({ id }: { id: string }) {
...
const publishPost = async (): Promise<void> => {
setLoading(true);
await fetch(`/api/publish/${id}`, {
method: "PUT",
});
...
};
return (
<button
...
>
{loading ? <LoadingDots color="#808080" /> : "PUBLISH"}
</button>
);
}
现在打开页面http://localhost:3000/protected
看下效果吧
点击PUBLISH
进行发布
发布成功🎉!
删除的操作与发布差不多,这里就不放截图了
首页与文章详情页
现在来到最后一步也是最简单的一步了,在./app/page.tsx
中修改代码如下
import Link from "next/link";
import prisma from "../lib/prisma";
export default async function Home() {
const posts = await prisma.post.findMany({
where: {
published: true,
},
include: {
author: {
select: { email: true, nickname: true },
},
},
});
return (
<div className="relative z-200">
{posts?.length > 0 ? (
<div className="relative isolate px-6 pt-14 lg:px-8">
<div className="w-3/5 mx-auto ">
<ul role="list" className="divide-y divide-black-400 ">
{posts?.map((post) => (
<li key={post.id}>
<Link
href={`/detail/${post.id}`}
className="flex justify-between gap-x-6 py-5 group"
>
<div className="flex gap-x-4">
<div className="min-w-0 flex-auto">
<p className="text-sm font-semibold leading-6 text-gray-900 group-hover:text-indigo-500">
{post.title}
</p>
<p className="mt-1 truncate text-xs leading-5 text-gray-500 text-ellipsis w-60">
{post.content}
</p>
</div>
</div>
<div className="hidden sm:flex sm:flex-col sm:items-end">
<p className="text-sm leading-6 text-gray-900">
@{post?.author?.nickname}
</p>
<p className="mt-1 truncate text-xs text-gray-500 whitespace-nowrap">
{post.createdAt.toLocaleDateString()}
</p>
</div>
</Link>
</li>
))}
</ul>
</div>
</div>
) : (
<div className="place-items-center mt-96">
<div className="text-center text-lg">
Nothing here 👀 , Make the first post 🎉
</div>
<div className="text-center mt-5">
<Link
href="/protected"
className="text-white bg-indigo-500 rounded-md p-2 hover:bg-indigo-400 text-sm"
>
new post
</Link>
</div>
</div>
)}
</div>
);
}
在首页我们直接查询所有已发布
文章并进行展示即可,效果如下
随后新建./app/detail/[id]/page.tsx
页面并添加代码
import prisma from "@/lib/prisma";
export default async function Detail(props) {
const postId = props.params.id;
const post = await prisma.post.findUnique({
where: {
id: postId,
},
include: {
author: {
select: { email: true, nickname: true },
},
},
});
return (
<div className="p-10 w-1/2 mx-auto flex-col justify-center align-middle">
<div className="text-4xl">{post?.title}</div>
<div className="mt-3">@{post?.author?.nickname}</div>
<div className="text-gray-400 text-sm mt-3">{post?.createdAt?.toLocaleDateString()}</div>
<div className="mt-3">{post?.content}</div>
</div>
);
}
这里获取props
中的文章id
后查询文章详情和作者信息进行展示即可。我们点击首页任意文章后查看效果
结语
经过本章的学习相信你已经熟悉了prisma
的基本CRUD
操作,恭喜你走出新手村
,成为了一名初级点点点&CRUD全干工程师
啦🎉!
如果你只是阅读本篇文章但并没有上手实践,我强烈建议你结合上一篇文章自己动手实现一下本项目
好了,以上就是本章的全部内容了,如果你觉得阅读后对你有所帮助的话,不妨点赞
+收藏
+关注
支持一下我哦👍,持续更新,我们下期再见👋