次世代全干工程师养成计划(下)

529 阅读5分钟

在我的上一篇文章中,带领大家用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 框架

如果你还不了解vecel、next-js相关技术的话建议先学习一下上篇文章或阅读官方文档后再进行本篇文章的学习哦

制定需求

本篇文章我们就拿大家最熟悉的博客系统来练习,抛砖引玉,你熟悉了PRISMA基本的CRUD操作后可以将其实践于任何你想实现的业务上

  1. 创建文章CREATE
  2. 发布文章UPDATE
  3. 展示文章READ
  4. 删除文章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表结构已经更新

image.png image.png

修改注册流程

由于我们需要展示每篇文章的作者信息,所以在注册时我们要有用户昵称的提交逻辑

./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>
  );
}

修改后页面效果如下

image.png

文章创建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方法,在获取到客户端传来的titlecontent参数后,调用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引入此页面即可,实现效果如下

image.png

创建第一篇文章,成功!🎉

image.png

由于我们还没有文章的展示页,所以到数据库中去验证一下

image.png

至此,我们已经完成了文章的创建工作,接下来让我们一鼓作气把剩下的RUD都搞定吧💪

文章发布/删除API

创建./app/api/publish/[id]./app/api/delete/[id]两个文件夹,并分别在这两个文件夹中创建route.ts

注意[id]是文件夹的名称,使用过umi-js的同学应该不会陌生,这是约定式动态路由的写法,想要了解更多请参考官方文档相关部分next-js中用这种方式所创建的路由将会匹配符合/api/publish/[id]/api/delete/[id]格式的所有请求

项目结构如下

image.png

分别键入以下代码

//./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后调用updatedelete方法对文章进行发布和删除操作

文章发布/删除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出了文章作者的emailnickname字段,使其可以在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看下效果吧

image.png

点击PUBLISH进行发布

image.png

发布成功🎉!

image.png

删除的操作与发布差不多,这里就不放截图了

首页与文章详情页

现在来到最后一步也是最简单的一步了,在./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>
  );
}

在首页我们直接查询所有已发布文章并进行展示即可,效果如下

image.png

随后新建./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后查询文章详情和作者信息进行展示即可。我们点击首页任意文章后查看效果

image.png

结语

经过本章的学习相信你已经熟悉了prisma的基本CRUD操作,恭喜你走出新手村,成为了一名初级点点点&CRUD全干工程师啦🎉!

如果你只是阅读本篇文章但并没有上手实践,我强烈建议你结合上一篇文章自己动手实现一下本项目

好了,以上就是本章的全部内容了,如果你觉得阅读后对你有所帮助的话,不妨点赞+收藏+关注支持一下我哦👍,持续更新,我们下期再见👋

往期文章

# 次世代全干工程师养成计划(上)

# 如何使用chatgpt提升前端开发效率?

# JS设计模式在React-hook中的实践

# 十个例子理解TS泛型