原文链接
上一篇【Next.js 项目实战系列】07-分配 Issue 给用户
数据处理
筛选
添加筛选按钮
TypeScript
# /app/issues/IssueStatusFilter.tsx
"use client";
import { Status } from "@prisma/client";
import { Select } from "@radix-ui/themes";
// 将所有可选值存储到外置 const 里
const statuses: { label: string; value?: Status }[] = [
{ label: "All" },
{ label: "Open", value: "OPEN" },
{ label: "In Progress", value: "IN_PROGRESS" },
{ label: "Closed", value: "CLOSED" },
];
const IssueStatusFilter = () => {
return (
<Select.Root defaultValue=" ">
<Select.Trigger placeholder="Filter by status..." />
<Select.Content>
{statuses.map((status) => (
<Select.Item key={status.value} value={status.value || " "}>
{status.label}
</Select.Item>
))}
</Select.Content>
</Select.Root>
);
};
export default IssueStatusFilter;
最终效果如下:
添加筛选参数
在刚刚的 IssueStatusFilter 中,为 Selet 添加 OnValueChage,使得在选项改变时,跳转至添加参数的页面 /issues?status=
TypeScript
# /app/issues/IssueStatusFilter.tsx
...
+ import { useRouter } from "next/navigation";
...
const IssueStatusFilter = () => {
+ const router = useRouter();
+ const setStatusFilter = (status: string) => {
+ if (status === "All") {
+ router.push("/issues");
+ return;
+ }
+ const query = status ? `?status=${status}` : "";
+ router.push("/issues" + query);
+ };
return (
+ <Select.Root defaultValue="All" onValueChange={setStatusFilter}>
...
</Select.Root>
);
};
export default IssueStatusFilter;
处理筛选参数
TypeScript
# /app/issues/page.tsx
...
+ interface Props {
+ searchParams: { status: Status };
+ }
+ const IssuesPage = async ({ searchParams }: Props) => {
// 判断 status 是否合法,若合法则加入到筛选项,若不合法则换成 undefined
+ const status = Object.values(Status).includes(searchParams.status)
+ ? searchParams.status
+ : undefined;
// prisma 获取数据时直接添加参数
+ const issues = await prisma.issue.findMany({
+ where: {
+ status,
+ },
+ });
...
};
export default IssuesPage;
最终效果如下
排序
本节更多的是 TypeScript 技巧
TypeScript
# /app/issues/page.tsx
...
interface Props {
- searchParams: { status: Status };
+ searchParams: { status: Status; orderBy: keyof Issue };
}
// 设置 className 为可选,对于一些固定值可以使用 keyof
+ const columns: { label: string; value: keyof Issue; className?: string }[] = [
+ { label: "Issue", value: "title" },
+ { label: "Status", value: "status", className: "hidden md:table-cell" },
+ { label: "Created", value: "createdAt", className: "hidden md:table-cell" },
+ ];
const IssuesPage = async ({ searchParams }: Props) => {
const status = Object.values(Status).includes(searchParams.status)
? searchParams.status
: undefined;
// 判断是否在其中可以使用 .includes()
// 如果是判断一个对象数组中的某一个键,可以像下面这样,先map成一个数组,再 .includes()
+ const orderBy = columns
+ .map((column) => column.value)
+ .includes(searchParams.orderBy)
+ ? { [searchParams.orderBy]: "asc" }
+ : undefined;
const issues = await prisma.issue.findMany({
where: {
status,
},
// got-add-next-line
+ orderBy,
});
return (
<div>
<IssueActions />
<Table.Root variant="surface">
<Table.Header>
<Table.Row>
+ {columns.map((column) => (
+ <Table.ColumnHeaderCell
+ key={column.label}
+ className={column.className}
+ >
+ <NextLink
+ href={{
{/* 使用 ... 展开数组*/}
+ query: { ...searchParams, orderBy: column.value },
+ }}
+ >
+ {column.label}
+ {column.value === searchParams.orderBy && (
+ <ArrowUpIcon className="inline" />
+ )}
+ </NextLink>
+ </Table.ColumnHeaderCell>
+ ))}
</Table.Row>
</Table.Header>
...
</Table.Root>
</div>
);
};
export const dynamic = "force-dynamic";
export default IssuesPage;
这里更多的是讲,多个参数时的处理方式
TypeScript
# /app/issues/IssueStatusFilter.tsx
...
+ import { useRouter, useSearchParams } from "next/navigation";
const IssueStatusFilter = () => {
const router = useRouter();
// 获取搜索参数
+ const searchParams = useSearchParams();
const setStatusFilter = (status: string) => {
// 创建一个空的 searchParams
+ const params = new URLSearchParams();
// 获取其他现有的 searchParams
+ if (searchParams.get("orderBy"))
+ params.append("orderBy", searchParams.get("orderBy")!);
// 善用三元表达式
+ if (status) params.append("status", status === "All" ? "All" : status);
+ const query = params.size ? "?" + params.toString() : "";
router.push("/issues" + query);
};
return (
<Select.Root
{/* 别忘了设置初始值 */}
+ defaultValue={searchParams.get("status") || "All"}
onValueChange={setStatusFilter}
>
<Select.Trigger placeholder="Filter by status..." />
<Select.Content>
{statuses.map((status) => (
<Select.Item
key={status.value || "All"}
value={status.value || "All"}
>
{status.label}
</Select.Item>
))}
</Select.Content>
</Select.Root>
);
};
export default IssueStatusFilter;
Dummy Data
使用如下提示词去问 AI 要一些数据(用于模拟)
Given the following prisma model, generate SQL statement to insert 20 records in the issues table. Use real-world titles and descriptions for issues. Status can be OPEN, IN_PROGRESS or CLOSED. Description should be a paragraph long with Mark down synatx. Provide different values for the createdAt and updatedAt columns.
分页
- Pagination.tsx
- page.tsx
TypeScript
# /app/issues/page.tsx
...
+ import Pagination from "../components/Pagination";
interface Props {
- searchParams: { status: Status; orderBy: keyof Issue };
+ searchParams: { status: Status; orderBy: keyof Issue; page: string };
}
...
const IssuesPage = async ({ searchParams }: Props) => {
...
+ const page = parseInt(searchParams.page) || 1;
+ const pageSize = 10;
+ const where = { status };
const issues = await prisma.issue.findMany({
+ where,
orderBy,
+ skip: (page - 1) * pageSize,
+ take: pageSize,
});
+ const issueCount = await prisma.issue.count({ where });
return (
<div>
<IssueActions />
<Table.Root variant="surface">
...
</Table.Root>
+ <Pagination
+ pageSize={pageSize}
+ currentPage={page}
+ itemCount={issueCount}
+ />
</div>
);
};
export const dynamic = "force-dynamic";
export default IssuesPage;
TypeScript
# /app/issues/page.tsx
...
+ import Pagination from "../components/Pagination";
interface Props {
- searchParams: { status: Status; orderBy: keyof Issue };
+ searchParams: { status: Status; orderBy: keyof Issue; page: string };
}
...
const IssuesPage = async ({ searchParams }: Props) => {
...
+ const page = parseInt(searchParams.page) || 1;
+ const pageSize = 10;
+ const where = { status };
const issues = await prisma.issue.findMany({
+ where,
orderBy,
+ skip: (page - 1) * pageSize,
+ take: pageSize,
});
+ const issueCount = await prisma.issue.count({ where });
return (
<div>
<IssueActions />
<Table.Root variant="surface">
...
</Table.Root>
+ <Pagination
+ pageSize={pageSize}
+ currentPage={page}
+ itemCount={issueCount}
+ />
</div>
);
};
export const dynamic = "force-dynamic";
export default IssuesPage;
重构与优化
- issueTable.tsx
- page.tsx
TypeScript
# /app/issues/issueTable.tsx
import { ArrowUpIcon } from "@radix-ui/react-icons";
import { Table } from "@radix-ui/themes";
import NextLink from "next/link";
import { IssueStatusBadge, Link } from "@/app/components";
import { Issue, Status } from "@prisma/client";
interface Props {
searchParams: IssueQuery;
issues: Issue[];
}
// 将 IssueQuery 定义为 interface
export interface IssueQuery {
status: Status;
orderBy: keyof Issue;
page: string;
}
const IssueTable = ({ searchParams, issues }: Props) => {
return (
<Table.Root variant="surface">
<Table.Header>
<Table.Row>
{columns.map((column) => (
<Table.ColumnHeaderCell
key={column.label}
className={column.className}
>
<NextLink
href={{
query: { ...searchParams, orderBy: column.value },
}}
>
{column.label}
{column.value === searchParams.orderBy && (
<ArrowUpIcon className="inline" />
)}
</NextLink>
</Table.ColumnHeaderCell>
))}
</Table.Row>
</Table.Header>
<Table.Body>
{issues.map((issue) => (
<Table.Row key={issue.id}>
<Table.Cell>
<Link href={`/issues/${issue.id}`}>{issue.title}</Link>
<div className="block md:hidden">
<IssueStatusBadge status={issue.status} />
</div>
</Table.Cell>
<Table.Cell className="hidden md:table-cell">
<IssueStatusBadge status={issue.status} />
</Table.Cell>
<Table.Cell className="hidden md:table-cell">
{issue.createdAt.toDateString()}
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
);
};
// 将 columns 定义在这里
const columns: { label: string; value: keyof Issue; className?: string }[] = [
{ label: "Issue", value: "title" },
{ label: "Status", value: "status", className: "hidden md:table-cell" },
{ label: "Created", value: "createdAt", className: "hidden md:table-cell" },
];
// 只把需要的内容导出
export const columnsNames = columns.map((column) => column.value);
export default IssueTable;
TypeScript
# /app/issues/page.tsx
import prisma from "@/prisma/client";
import { Status } from "@prisma/client";
import Pagination from "../components/Pagination";
import IssueActions from "./IssueActions";
import IssueTable, { IssueQuery, columnsNames } from "./IssueTable";
import { Flex } from "@radix-ui/themes";
interface Props {
searchParams: IssueQuery;
}
const IssuesPage = async ({ searchParams }: Props) => {
const status = Object.values(Status).includes(searchParams.status)
? searchParams.status
: undefined;
const orderBy = columnsNames.includes(searchParams.orderBy)
? { [searchParams.orderBy]: "asc" }
: undefined;
const page = parseInt(searchParams.page) || 1;
const pageSize = 10;
const where = { status };
const issues = await prisma.issue.findMany({
where,
orderBy,
skip: (page - 1) * pageSize,
take: pageSize,
});
const issueCount = await prisma.issue.count({ where });
return (
<Flex direction="column" gap="4">
<IssueActions />
<IssueTable searchParams={searchParams} issues={issues} />
<Pagination
pageSize={pageSize}
currentPage={page}
itemCount={issueCount}
/>
</Flex>
);
};
export const dynamic = "force-dynamic";
export default IssuesPage;
下一篇讲 Dashboard