跳到主要内容

创建 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 验证表单

/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 > 标签包起来

/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 页面

本节代码链接

/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;

显示效果如下 New Issue Page

Radix-UI 定义 UI 样式

本节代码链接

layout.tsx 中添加 <Themepanel >

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

效果如下

Theme Panel

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

/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 中修改

/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

效果如下:

Simple MarkDown Editor

提交表单

本节代码链接

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

npm i react-hook-form
npm i axios
/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 版)
/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;

效果如下:

submit form

Handle Error

本节代码链接

表单验证

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

/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

Error Callout

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

/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 移动到一个新文件中,并自动更新引用

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

Refactor 1

然后选择 move to a new file

Refactor 2

使用 Zod Schema 推断 interface

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

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

/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
/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;

最终效果如下:

Client Side Validation

将 ErrorMessage 封装

/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 中直接调用

/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 代码
/app/components/Spinner.tsx
const Spinner = () => {
return (
<div
className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-solid border-current border-e-transparent align-[-0.125em] text-surface motion-reduce:animate-[spin_1.5s_linear_infinite] dark:text-white"
role="status"
>
<span className="!absolute !-m-px !h-px !w-px !overflow-hidden !whitespace-nowrap !border-0 !p-0 ![clip:rect(0,0,0,0)]">
Loading...
</span>
</div>
);
};
export default Spinner;
/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>
);
};

最终版本

本节代码链接

最终版本完整代码(非 git diff 版)
/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;
请作者喝可乐🥤: