原文链接
上一篇【Next.js 项目实战系列】02-创建 Issue
查看 Issue
展示 Issue
首先使用 prisma 获取所有的 issues,然后添加一个 Radix UI 中的 Table 组件
TypeScript
# /app/issues/page.tsx
import { Button, Table, TableColumnHeaderCell } from "@radix-ui/themes";
import Link from "next/link";
+ import prisma from "@/prisma/client";
const IssuesPage = async () => {
+ const issues = await prisma.issue.findMany();
return (
<div>
<div className="mb-5">
<Button>
<Link href="/issues/new">New Issue</Link>
</Button>
</div>
{/* Radix UI 中的 Table 组件 */}
+ <Table.Root variant="surface">
+ <Table.Header>
+ <Table.Row>
+ <TableColumnHeaderCell>Issue</TableColumnHeaderCell>
+ <TableColumnHeaderCell>Status</TableColumnHeaderCell>
+ <TableColumnHeaderCell>Created</TableColumnHeaderCell>
+ </Table.Row>
+ </Table.Header>
+ <Table.Body>
+ {issues.map((issue) => (
+ <Table.Row key={issue.id}>
+ <Table.Cell>{issue.title}</Table.Cell>
+ <Table.Cell>{issue.status}</Table.Cell>
+ <Table.Cell>{issue.createdAt.toDateString()}</Table.Cell>
+ </Table.Row>
+ ))}
+ </Table.Body>
+ </Table.Root>
</div>
);
};
export default IssuesPage;
然后我们可以给不同的列添加显示选项,以适配不同的屏幕大小
TypeScript
# /app/issues/page.tsx
...
const IssuesPage = async () => {
...
return (
<div>
...
<Table.Root variant="surface">
<Table.Header>
<Table.Row>
<TableColumnHeaderCell>Issue</TableColumnHeaderCell>
<TableColumnHeaderCell className="hidden md:table-cell">
Status
</TableColumnHeaderCell>
<TableColumnHeaderCell className="hidden md:table-cell">
Created
</TableColumnHeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{issues.map((issue) => (
<Table.Row key={issue.id}>
<Table.Cell>
{issue.title}
<div className="block md:hidden">{issue.status}</div>
</Table.Cell>
<Table.Cell className="hidden md:table-cell">
{issue.status}
</Table.Cell>
<Table.Cell className="hidden md:table-cell">
{issue.createdAt.toDateString()}
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</div>
);
};
export default IssuesPage;
显示效果如下
制作 Badge
在 Prisma 中添加的 Model 会自动为我们生成 Type ,方便拿来做 Interface
这里有一些技巧。首先,对于一些固定值的映射(比如这里 Issue 状态对 Badge 颜色/内容的映射),我们可以使用一个 Record 来记录,其本质为一个键值对,我们可以使用 < >
来定义键和值的数据类型
TypeScript
# /app/components/IssueStatusBadge.tsx
import { Status } from "@prisma/client";
import { Badge } from "@radix-ui/themes";
const statusMap: Record<
Status,
{ label: string; color: "green" | "violet" | "red" }
> = {
OPEN: { label: "Open", color: "green" },
IN_PROGRESS: { label: "In Progress", color: "violet" },
CLOSED: { label: "Closed", color: "red" },
};
const IssueStatusBadge = ({ status }: { status: Status }) => {
return (
<Badge color={statusMap[status].color}>{statusMap[status].label}</Badge>
);
};
export default IssueStatusBadge;
最终效果如下
Loading Skeleton
本节我们想要实现一个如下的加载动画
安装 delay
包用于模拟网速较慢情况,react-loading-skeleton
包用于添加骨架动画
npm i delay
npm i react-loading-skeleton
首先,我们应该在页面中把不需要加载的部分(指不需要从外部获取数据的部分,一些写死的 Text, Link, Button 之类的)封装起来,
TypeScript
# /app/issues/IssueAction.tsx
import { Button } from "@radix-ui/themes";
import Link from "next/link";
const IssueActions = () => {
return (
<div className="mb-5">
<Button>
<Link href="/issues/new">New Issue</Link>
</Button>
</div>
);
};
export default IssueActions;
然后在 page.tsx
同目录下创建 loading.tsx
(注意文件名必须是这个,大小写也不能改)。将 page.tsx 中 return 的内容都复制到里面,把需要加载的字段换为 Skeleton 标签即可
TypeScript
# /app/issues/loading.tsx
import { Table, TableColumnHeaderCell } from "@radix-ui/themes";
// import Skeleton
+ import Skeleton from "react-loading-skeleton";
+ import "react-loading-skeleton/dist/skeleton.css";
import IssueActions from "./IssueActions";
const LoadingIssuesPage = () => {
// 显示 5 行 skeleton
+ const issues = [1, 2, 3, 4, 5];
return (
...
<Table.Body>
{issues.map((issue) => (
{/* 将所有需要数据的字段换为 <Skeleton />即可 */}
- <Table.Row key={issue.id}>
+ <Table.Row key={issue}>
<Table.Cell>
- {issue.title}
+ <Skeleton />
<div className="block md:hidden">
- <IssueStatusBadge status={issue.status} />
+ <Skeleton />
</div>
</Table.Cell>
<Table.Cell className="hidden md:table-cell">
- <IssueStatusBadge status={issue.status} />
+ <Skeleton />
</Table.Cell>
<Table.Cell className="hidden md:table-cell">
- {issue.createdAt.toDateString()}
+ <Skeleton />
</Table.Cell>
</Table.Row>
))}
</Table.Body>
...
);
};
export default LoadingIssuesPage;
我们可以在 page.tsx 中添加一个 delay(2000)
来模拟
TypeScript
# /app/issues/page.tsx
...
const IssuesPage = async () => {
const issues = await prisma.issue.findMany();
await delay(2000);
...
Issue Detail Page
首先创建一个页面用于展示 Issue 细节 /app/issues/[id]/page.tsx
TypeScript
# /app/issues/[id]/page.tsx
import prisma from "@/prisma/client";
import { notFound } from "next/navigation";
interface Props {
params: { id: string };
}
const IssueDeatilPage = async ({ params }: Props) => {
// 判断 url 中的 id 是不是 number,比如 'issues/abc' 就直接404
if (typeof params.id !== "number") notFound();
// 获取 issue
const issue = await prisma.issue.findUnique({
where: { id: parseInt(params.id) },
});
// 如果 issue 不存在,也404
if (!issue) notFound();
return (
<div>
<p>{issue.title}</p>
<p>{issue.description}</p>
<p>{issue.status}</p>
<p>{issue.createdAt.toDateString()}</p>
</div>
);
};
export default IssueDeatilPage;
然后在 /app/issues/page.tsx
中渲染表格时,添加一个 Link,用于跳转到 detail 页面
TypeScript
# /app/issues/page.tsx
const IssuesPage = async () => {
return (
...
<Table.Cell>
+ <Link href={`/issues/${issue.id}`}>{issue.title}</Link>
<div className="block md:hidden">
<IssueStatusBadge status={issue.status} />
</div>
</Table.Cell>
...
);
};
最后,我们应该为 "/app/issues" 下的每一个 page
都提供一个 loading.tsx
,否则刚刚的 "/app/issues/loading.tsx" 会应用到 "/app/issues" 下的所有页面
添加样式
此处大量使用了 Radix UI 中的组件
TypeScript
# /app/issues/[id]/page.tsx
const IssueDeatilPage = async ({ params }: Props) => {
...
return (
<div>
<Heading as="h2">{issue.title}</Heading>
<Flex gap="3" my="5">
<IssueStatusBadge status={issue.status}></IssueStatusBadge>
<Text>{issue.createdAt.toDateString()}</Text>
</Flex>
<Card>{issue.description}</Card>
</div>
);
};
效果如下
MarkDown 渲染
首先,安装以下两个 package
npm i react-markdown
npm install -D @tailwindcss/typography
在 "/app/issues/[id]/page.tsx" 中,将 issue.description 用 ReactMarkdown
组件包起来即可
TypeScript
# /app/issues/[id]/page.tsx
const IssueDeatilPage = async ({ params }: Props) => {
...
return (
...
- <Card>{issue.description}</Card>
+ <Card className="prose">
+ <ReactMarkdown>{issue.description}</ReactMarkdown>
+ </Card>
...
);
};
在 Tailwind 中,默认 h1, h2, ul, ol, strong 这些标签都是无样式的,我们需要手动进行设置。刚刚安装的 tailwindcss/typography
就是这个作用。首先,在 tailwind.config.ts
中,添加 plugin,然后在需要用到这些样式的 container 的 className
中添加 prose
即可
TypeScript
# tailwind.config.ts
import type { Config } from "tailwindcss";
const config: Config = {
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))",
},
},
},
// 在这里添加 @tailwindcss/typography"
+ plugins: [require("@tailwindcss/typography")],
};
export default config;
最终实现效果如下
自定义 Link Component
我们想要同时应用 Next.js 中 Link 的客户端导航功能,和 Radix UI 中 Link 的样式,就可以进行如下改装,后期直接使用即可
TypeScript
# /app/components/link.tsx
import NextLink from "next/link";
import { Link as RadixLink } from "@radix-ui/themes";
interface Props {
href: string;
children: string;
}
const Link = ({ href, children }: Props) => {
return (
<NextLink href={href} passHref legacyBehavior>
<RadixLink>{children}</RadixLink>
</NextLink>
);
};
export default Link;
效果如下,其中 Link 的颜色会随着 Theme 的改变而改变
Loading Skeletons
TypeScript
# /app/issues/[id]/loading.tsx
import IssueStatusBadge from "@/app/components/IssueStatusBadge";
import { Box, Card, Flex, Heading } from "@radix-ui/themes";
import ReactMarkdown from "react-markdown";
import Skeleton from "react-loading-skeleton";
import "react-loading-skeleton/dist/skeleton.css";
const LoadingIssueDetailPage = () => {
return (
<Box className="max-w-xl">
<Skeleton />
<Flex gap="3" my="5">
<Skeleton width="5rem" />
<Skeleton width="8rem" />
</Flex>
<Card className="prose">
<Skeleton count={3} />
</Card>
</Box>
);
};
export default LoadingIssueDetailPage;
TypeScript
# /app/issues/new/loading.tsx
import { Box } from "@radix-ui/themes";
import Skeleton from "react-loading-skeleton";
import "react-loading-skeleton/dist/skeleton.css";
const LoadingNewIssuePage = () => {
return (
<Box className="max-w-xl">
<Skeleton />
<Skeleton height="20rem" />
</Box>
);
};
export default LoadingNewIssuePage;
显示效果如下
动态导入(关闭 SSR)
SSR(Server Side Render ) 相关内容可参考组件的渲染
TypeScript
# /app/issues/new/page.tsx
- import SimpleMDE from "react-simplemde-editor";
+ import dynamic from "next/dynamic";
+ const SimpleMDE = dynamic(() => import("react-simplemde-editor"), {
+ ssr: false,
+ });
整理 imports
我们可以在 Components 文件夹下添加 index.ts
,将该文件夹下所有组件都注册到其中
TypeScript
# /app/components/index.ts
export { default as Link } from "./Link";
export { default as ErrorMessage } from "./ErrorMessage";
export { default as IssueStatusBadge } from "./IssueStatusBadge";
export { default as Spinner } from "./Spinner";
export { default as Skeleton } from "./Skeleton";
然后在其他页面,直接使用以下 import 语句即可
import { ErrorMessage, Spinner } from "@/app/components";
下一篇讲修改 Issue