【Next.js 项目实战系列】02-创建 Issue

原文链接

CSDN 的排版/样式可能有问题,去我的博客查看原文系列吧,觉得有用的话,给我的点个star,关注一下吧

上一篇**【Next.js 项目实战系列】01-创建项目**

创建 Issue

配置 MySQL 与 Prisma

数据库中可以找到相关内容,这里不再赘述

添加 model

本节代码链接

复制代码
# schema.prisma

model Issue {
  id Int @id @default(autoincrement())
  title String @db.VarChar(255)
  description String @db.Text
  status Status @default(OPEN)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt()
}

enum Status {
  OPEN
  IN_PROGRESS
  CLOSED
}

使用以下指令同步到数据库

复制代码
npx prisma format
npx prisma migrate dev

编写 API

本节代码链接

这里使用 zod 来验证表单,具体内容可参考使用 zod 验证表单

TypeScript 复制代码
# /app/api/issues/route.ts

import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import prisma from "@/prisma/client";

const createIssueSchema = z.object({
  title: z.string().min(1).max(255),
  description: z.string().min(1),
});

export async function POST(request: NextRequest) {
  const body = await request.json();
  const validation = createIssueSchema.safeParse(body);
  if (!validation.success)
    return NextResponse.json(validation.error.errors, { status: 400 });

  const newIssue = await prisma.issue.create({
    data: { title: body.title, description: body.description },
  });

  return NextResponse.json(newIssue, { status: 201 });
}

Radix-UI

本节代码链接

radix-ui 也是一个类 DaisyUI 的组件库,使用如下指令安装

复制代码
npm install @radix-ui/themes

安装好后,进行如下初始配置,将主 layout 中的所有内容用 <Theme > 标签包起来

TypeScript 复制代码
# /app/layout.tsx

  import type { Metadata } from "next";
+ import "@radix-ui/themes/styles.css";
  import { Inter } from "next/font/google";
+ import { Theme } from "@radix-ui/themes";
  import "./globals.css";
  import NavBar from "./NavBar";

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

  export const metadata: Metadata = {
    title: "Create Next App",
    description: "Generated by create next app",
  };

  export default function RootLayout({
    children,
  }: Readonly<{
    children: React.ReactNode;
  }>) {
    return (
      <html lang="en">
        <body className={inter.className}>
+         <Theme>
            <NavBar />
            <main>{children}</main>
+         </Theme>
        </body>
      </html>
    );
  }

创建新 Issue 页面

本节代码链接

TypeScript 复制代码
# /app/issues/new/page.tsx

"use client";
import { Button, TextArea, TextField } from "@radix-ui/themes";

const NewIssuePage = () => {
  return (
    <div className="max-w-xl space-y-3">
      <TextField.Root>
        <TextField.Input placeholder="Title" />
      </TextField.Root>
      <TextArea placeholder="Description" />
      <Button>Submit New</Button>
    </div>
  );
};
export default NewIssuePage;

显示效果如下

Radix-UI 定义 UI 样式

本节代码链接

layout.tsx 中添加 <Themepanel >

TypeScript 复制代码
# /app/layout.tsx

+ import { Theme, ThemePanel } from "@radix-ui/themes";
  ...
  return (
    <html lang="en">
      <body className={inter.className}>
        <Theme>
          <NavBar />
          <main className="p-5">{children}</main>
+           <ThemePanel />
        </Theme>
      </body>
    </html>
  );
  ...

效果如下

调整好自己想要的样式之后点击 Copy Theme,将 copy 到的 <Theme > 标签替换掉原来的即可

TypeScript 复制代码
  #  /app/layout.tsx
  ...
  return (
    <html lang="en">
      <body className={inter.className}>
        {/*添加到这里即可*/}
        <Theme appearance="light" accentColor="violet">
          <NavBar />
          <main className="p-5">{children}</main>
        </Theme>
      </body>
    </html>
  );
  ...

设置字体

在 Radix-UI 中设置字体需要以下步骤,可以参考 radix-ui-font

首先在 layout.tsx 中修改

TypeScript 复制代码
# /app/layout.tsx

  import { Theme } from "@radix-ui/themes";
  import "@radix-ui/themes/styles.css";
  import type { Metadata } from "next";
  import { Inter } from "next/font/google";
  import NavBar from "./NavBar";
  import "./globals.css";
- const inter = Inter({ subsets: ["latin"] });
+ const inter = Inter({
+   subsets: ['latin'],
+   variable: '--font-inter',
+ });
  export const metadata: Metadata = {
    title: "Create Next App",
    description: "Generated by create next app",
  };

  export default function RootLayout({
    children,
  }: Readonly<{
    children: React.ReactNode;
  }>) {
    return (
      <html lang="en">
-       <body className={inter.className}>
+       <body className={inter.variable}>
          <Theme appearance="light" accentColor="violet">
            <NavBar />
            <main className="p-5">{children}</main>
          </Theme>
        </body>
      </html>
    );
  }

然后添加 /app/theme-config.css 并添加以下内容

/app/theme-config.css

复制代码
.radix-themes {
  --default-font-family: var(--font-inter);
}

最后在 layout.tsx 中 import 进来即可

复制代码
···
import "./theme-config.css";
···

MarkDown Editor

本节代码链接

react-simlemde-editor 是一款集成式 MarkDown 编辑器,使用如下命令安装

复制代码
npm install --save react-simplemde-editor easymde

效果如下:

提交表单

本节代码链接

我们使用 react-hook-formaxios 进行表单提交

复制代码
npm i react-hook-form
npm i axios
TypeScript 复制代码
# /app/issues/new/page.tsx

  "use client";
  import { Button, TextField } from "@radix-ui/themes";
  import { useRouter } from "next/navigation";
  // import
+ import axios from "axios";
+ import "easymde/dist/easymde.min.css";
+ import { Controller, useForm } from "react-hook-form";
+ import SimpleMDE from "react-simplemde-editor";

  // 使用 interface 表明 form 中有哪些内容
+ interface IssueForm {
+   title: string;
+   description: string;
+ }

  const NewIssuePage = () => {
    // 使用 React Hook Form
+   const { register, control, handleSubmit } = useForm<IssueForm>();
    // 使用 router 进行页面跳转
+   const router = useRouter();

    return (
      {/* 将最外层 div 换为 form */}
+     <form className="max-w-xl space-y-3"
+       onSubmit={handleSubmit(async (data) => {
          {/* 使用 axios 进行 post */}
+         await axios.post("/api/issues", data);
+         router.push("/issues");
+       })}>
        <TextField.Root>
          {/* 将该组件注册为 form 中的 title 字段 */}
+         <TextField.Input placeholder="Title" {...register("title")} />
        </TextField.Root>
        {/* 由于 simpleMDE 不能直接像上面的 Input 一样传入参数,我们这里使用 React Hook Form 中的 Controller */}
-       <SimpleMDE placeholder="Description" />
+       <Controller
+         name="description"
+         control={control}
+         render={({ field }) => (
+           <SimpleMDE placeholder="Description" {...field} />
+         )}
+       />
        <Button>Submit New</Button>
+     </form>
    );
  };
  export default NewIssuePage;

完整代码(非 git diff 版)

TypeScript 复制代码
# /app/issues/new/page.tsx

"use client";
import { Button, TextField } from "@radix-ui/themes";
import axios from "axios";
import "easymde/dist/easymde.min.css";
import { useRouter } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
import SimpleMDE from "react-simplemde-editor";

interface IssueForm {
  title: string;
  description: string;
}

const NewIssuePage = () => {
  const { register, control, handleSubmit } = useForm<IssueForm>();
  const router = useRouter();

  return (
    <form
      className="max-w-xl space-y-3"
      onSubmit={handleSubmit(async (data) => {
        await axios.post("/api/issues", data);
        router.push("/issues");
      })}
    >
      <TextField.Root>
        <TextField.Input placeholder="Title" {...register("title")} />
      </TextField.Root>
      <Controller
        name="description"
        control={control}
        render={({ field }) => (
          <SimpleMDE placeholder="Description" {...field} />
        )}
      />
      <Button>Submit New</Button>
    </form>
  );
};
export default NewIssuePage;

效果如下:

Handle Error

本节代码链接

表单验证

之前说到,我们使用 zod 进行表单验证,可以在使用 zod 时,自定义报错内容

TypeScript 复制代码
# /app/api/issues/new/route.tsx

  ...
  const createIssueSchema = z.object({
    // 在定义时,可以加第二个参数,表示如果未满足该项时的报错
+   title: z.string().min(1, "Title is required!").max(255),
+   description: z.string().min(1, "Description is required!"),
  });

  export async function POST(request: NextRequest) {
    ...
    if (!validation.success)
    // 改为调用 validation.error.format()
-     return NextResponse.json(validation.error.errors, { status: 400 });
+     return NextResponse.json(validation.error.format(), { status: 400 });
    ...
  }

报错显示

接下来实现一个这样的 Error Callout

/app/issues/new/page.tsx 中修改。把 axios 的相关内容放到一个 try-catch block 里

TypeScript 复制代码
# /app/issues/new/page.tsx

  "use client";
  ...
  const NewIssuePage = () => {
    ...
    // 添加 useState 变量
+   const [error, setError] = useState("");

    return (
        ...
        {/*若报错则显示一个 CallOut*/}
+       {error && (
+         <Callout.Root color="red" className="mb-5">
+           <Callout.Text>{error}</Callout.Text>
+         </Callout.Root>
+       )}
        <form
          className="space-y-3"
          onSubmit={handleSubmit(async (data) => {
            // 报错时设置 error
+           try {
+             await axios.post("/api/issues", data);
+             router.push("/issues");
+           } catch (error) {
+             setError("An unexpected Error occured!");
+           }
          })}
        >
        ...
  };
  export default NewIssuePage;

用户端验证

本节代码链接

Zod schema

我们在用户端验证时,也需要用到刚刚 zod 中编辑的 schema,为此我们应该将其移动到一个单独的文件中。在 VS Code 中 可以方便的进行重构,将 createIssueSchema 移动到一个新文件中,并自动更新引用

首先右键想要重构的变量,点击 重构

然后选择 move to a new file

使用 Zod Schema 推断 interface

将刚刚移出的 schema 移动到 /app 目录下,重命名为 validationSchema.ts

之前在 new page 中,我们定义了一个 interface,用于定义表单,但其实与我们在 zod 中定义的内容是重复的,如果我们之后还需要增删内容,需要在两边修改,较为麻烦。我们可以直接使用刚刚的 zod schema 来定义 interface ,如下所示

TypeScript 复制代码
# /app/issues/new/page.tsx

+  import { createIssueSchema } from "@/app/validationSchema";
+  import { z } from "zod";
- interface IssueForm {
-   title: string;
-   description: string;
- }
+  type IssueForm = z.infer<typeof createIssueSchema>;

使用 hookform 集成 zod 验证表单

安装 hookform/resolvers,用于将 React Hook Form 插件使用表单验证插件(比如 zod)

复制代码
npm i @hookform/resolvers
TypeScript 复制代码
# /app/issues/new/page.tsx
  
  "use client";
  ...
  // import
+ import { Button, Callout, Text, TextField } from "@radix-ui/themes";
+ import { zodResolver } from "@hookform/resolvers/zod";

  type IssueForm = z.infer<typeof createIssueSchema>;

  const NewIssuePage = () => {
    const {
      register,
      control,
      handleSubmit,
      // errors 则为验证结果
+     formState: { errors },
    } = useForm<IssueForm>({
      // 将 zodResoler 传入,以验证表单
+     resolver: zodResolver(createIssueSchema),
    });
    ...

    return (
      <div className="max-w-xl">
        ...
        <TextField.Root>
          <TextField.Input placeholder="Title" {...register("title")} />
        </TextField.Root>
        {/* 根据验证结果来显示提示,此处为 title 字段的信息 */}
+       {errors.title && (
+         <Text color="red" as="p">
+           {errors.title.message}
+         </Text>
+       )}
        <Controller
          name="description"
          control={control}
          render={({ field }) => (
            <SimpleMDE placeholder="Description" {...field} />
          )}
        />
        {/* 根据验证结果来显示提示,此处为 description 字段的信息 */}
+       {errors.description && (
+         <Text color="red" as="p">
+           {errors.description.message}
+         </Text>
+       )}
        ...
      </div>
    );
  };
  export default NewIssuePage;

最终效果如下:

将 ErrorMessage 封装

TypeScript 复制代码
# /app/components/ErrorMessage.tsx

import { Text } from "@radix-ui/themes";
import { PropsWithChildren } from "react";

const ErrorMessage = ({ children }: PropsWithChildren) => {
  if (!children) return null;
  return (
    <Text color="red" as="p">
      {children}
    </Text>
  );
};
export default ErrorMessage;

然后我们可以在 new Page 中直接调用

TypeScript 复制代码
# /app/issues/new/page.tsx

  "use client";
  ...
  // import
+ import ErrorMessage from "@/app/components/ErrorMessage";

    return (
      <div className="max-w-xl">
        ...
        {/* 根据验证结果来显示提示,此处为 title 字段的信息 */}
-       {errors.title && (
-         <Text color="red" as="p">
-           {errors.title.message}
-         </Text>
-       )}
+       <ErrorMessage>{errors.title?.message}</ErrorMessage>
        ...
-       {errors.description && (
-         <Text color="red" as="p">
-           {errors.description.message}
-         </Text>
-       )}
+        <ErrorMessage>{errors.description?.message}</ErrorMessage>
        ...
      </div>
    );
  };
  export default NewIssuePage;

Button 优化技巧

本节代码链接

首先我们可以添加一个 Spinner 给 Button。其次,我们可以给 Button 添加一个 disabled 属性,使得其只能被点击一次,避免多次提交表单

Spinner 代码

TypeScript 复制代码
# /app/issues/new/page.tsx

+ import Spinner from "@/app/components/Spinner";

  const NewIssuePage = () => {
+   const [isSubmitting, setSubmitting] = useState(false);

    return (
      <div className="max-w-xl">
        ...
        <form
          className="space-y-3"
          onSubmit={handleSubmit(async (data) => {
            try {
+             setSubmitting(true);
              await axios.post("/api/issues", data);
              router.push("/issues");
            } catch (error) {
+             setSubmitting(false);
              setError("An unexpected Error occured!");
            }
          })}
        >
          ...
+         <Button disabled={isSubmitting}>
+           Submit New Issue {isSubmitting && <Spinner />}
+         </Button>
        </form>
      </div>
    );
  };

最终版本

本节代码链接

TypeScript 复制代码
/app/issues/new/page.tsx

"use client";
import { Button, Callout, Text, TextField } from "@radix-ui/themes";
import axios from "axios";
import "easymde/dist/easymde.min.css";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import SimpleMDE from "react-simplemde-editor";
import { zodResolver } from "@hookform/resolvers/zod";
import { createIssueSchema } from "@/app/validationSchema";
import { z } from "zod";
import ErrorMessage from "@/app/components/ErrorMessage";

type IssueForm = z.infer<typeof createIssueSchema>;

const NewIssuePage = () => {
  const {
    register,
    control,
    handleSubmit,
    formState: { errors },
  } = useForm<IssueForm>({
    resolver: zodResolver(createIssueSchema),
  });
  const router = useRouter();
  const [error, setError] = useState("");

  return (
    <div className="max-w-xl">
      {error && (
        <Callout.Root color="red" className="mb-5">
          <Callout.Text>{error}</Callout.Text>
        </Callout.Root>
      )}
      <form
        className="space-y-3"
        onSubmit={handleSubmit(async (data) => {
          try {
            await axios.post("/api/issues", data);
            router.push("/issues");
          } catch (error) {
            setError("An unexpected Error occured!");
          }
        })}
      >
        <TextField.Root>
          <TextField.Input placeholder="Title" {...register("title")} />
        </TextField.Root>
        <ErrorMessage>{errors.title?.message}</ErrorMessage>
        <Controller
          name="description"
          control={control}
          render={({ field }) => (
            <SimpleMDE placeholder="Description" {...field} />
          )}
        />
        <ErrorMessage>{errors.description?.message}</ErrorMessage>
        <Button>Submit New</Button>
      </form>
    </div>
  );
};
export default NewIssuePage;

CSDN 的排版/样式可能有问题,去我的博客查看原文系列吧,觉得有用的话,给我的点个star,关注一下吧

下一篇讲查看 Issue

下一篇【Next.js 项目实战系列】03-查看 Issue

相关推荐
aiguangyuan2 小时前
浅谈 React Hooks
react·前端开发
whatever who cares2 天前
React hook之userReducer
react.js·react
aiguangyuan2 天前
React Hooks 基础指南
react·前端开发
aiguangyuan3 天前
React 项目初始化与搭建指南
react·前端开发
aiguangyuan3 天前
React 组件异常捕获机制详解
react·前端开发
aiguangyuan3 天前
深入理解 JSX:React 的核心语法
react·前端开发
aiguangyuan4 天前
React 基础语法
react·前端开发
山水域4 天前
0基础入门AI编程之环境篇Devbox
next.js
aiguangyuan5 天前
React 核心概念与生态系统
react·前端开发
aiguangyuan5 天前
React 18 生命周期详解与并发模式下的变化
react·前端开发