next.js-学习4
- 11.添加搜索和分页
- 添加搜索框
- 搜索表格数据
- 防抖
- 分页
- 12.增删改票据
- 1. 创建票据
- 1.1测试添加
- 2. 更新票据
- 2.1测试更新
- 3. 删除票据
- 3.1测试删除
- 13.错误处理
- 1.添加try/catch
- 1.1 测试删除失败
- 2.处理错误
- 错误处理文档链接
- 14.提高易用性
- 1. eslint-plugin-jsx-a11y
- 2. 表单验证
- 2.1客户端验证
- 2.2服务端验证
- 2.2.1添加
- 3.2 编辑
更新下home菜单的路由,app/ui/dashboard/nav-links.tsx中link的 { name: ‘Home’, href: ‘/dashboard’, icon: HomeIcon },改为 { name: ‘Home’, href: ‘/dashboard/overview’, icon: HomeIcon },
11.添加搜索和分页
添加搜索框
-
/dashboard/invoices/page.tsx中添加
import Pagination from '@/app/ui/invoices/pagination'; import Search from '@/app/ui/search'; import Table from '@/app/ui/invoices/table'; import { CreateInvoice } from '@/app/ui/invoices/buttons'; import { lusitana } from '@/app/ui/fonts'; import { InvoicesTableSkeleton } from '@/app/ui/skeletons'; import { Suspense } from 'react';export default async function Page() {return (<div className="w-full"><div className="flex w-full items-center justify-between"><h1 className={`${lusitana.className} text-2xl`}>Invoices</h1></div><div className="mt-4 flex items-center justify-between gap-2 md:mt-8"><Search placeholder="Search invoices..." /><CreateInvoice /></div>{/* <Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}><Table query={query} currentPage={currentPage} /></Suspense> */}<div className="mt-5 flex w-full justify-center">{/* <Pagination totalPages={totalPages} /> */}</div></div>); }
-
添加个搜索/app/ui/search.tsx中
//export default function Search({ placeholder }: { placeholder: string }) {中添加 function handleSearch(term: string) {console.log(term);}//input中 placeholder={placeholder}下边添加修改监听事件onChange={(e) => {handleSearch(e.target.value);}}
跳转到http://localhost:3000/dashboard/invoices路由搜索就会看到搜索的输入内容打印。
继续修改/app/ui/search.tsx,导入组件,修改Search方法,useSearchParams可以封装url参数
import { useSearchParams } from 'next/navigation'; export default function Search() {const searchParams = useSearchParams();function handleSearch(term: string) {const params = new URLSearchParams(searchParams);if (term) {params.set('query', term);} else {params.delete('query');}}// ... }
继续增加路由path,这样输入搜索在导航栏会显示
import { useSearchParams, usePathname, useRouter } from 'next/navigation';//更新导入的组件 //searchParams下加入const pathname = usePathname();const { replace } = useRouter();//在handleSearch函数最后一行写入,pathname是请求的路径dashboard/invoices,params.toString()是请求的参数就说问号后边的那一串replace(`${pathname}?${params.toString()}`);
为了确保输入字段与URL同步,并在共享时填充,您可以通过从searchParams中读取defaultValue来传递输入:
defaultValue={searchParams.get('query')?.toString()}//加到onChange事件下边
/app/dashboard/invoices/page.tsx中,Page函数增加参数
props: {searchParams?: Promise<{query?: string;page?: string;}>; }
函数第一行,获取参数
const searchParams = await props.searchParams;const query = searchParams?.query || '';const currentPage = Number(searchParams?.page) || 1;
搜索表格数据
-
打开注释,给/app/ui/invoices/table.tsx传参,返回表格数据
<Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}><Table query={query} currentPage={currentPage} /></Suspense>
可以看到页面搜索出来数据了
防抖
/app/ui/search.tsx中使用防抖,handleSearch函数上边添加useDebouncedCallback回调0.3秒不输入才去搜索
下载React 防抖
pnpm i use-debounce
// 直接在组件内定义 useDebouncedCallbackconst debouncedSearch = useDebouncedCallback((term: string) => {console.log(`Searching... ${term}`);const params = new URLSearchParams(searchParams.toString()); // 确保是字符串格式if (term) {params.set('query', term);} else {params.delete('query');}replace(`${pathname}?${params.toString()}`);}, 300);// 事件处理函数调用 debouncedSearchfunction handleSearch(term: string) {debouncedSearch(term);}
分页
-
/app/dashboard/invoices/page.tsx中加入
import { fetchInvoicesPages } from '@/app/lib/data';<Pagination totalPages={totalPages} />//打开注释
-
/app/ui/invoices/pagination.tsx加入
import { usePathname, useSearchParams } from 'next/navigation'; //Pagination函数中加入,解开这个页面的注释const pathname = usePathname();const searchParams = useSearchParams();const currentPage = Number(searchParams.get('page')) || 1;//创建搜索urlconst createPageURL = (pageNumber: number | string) => {const params = new URLSearchParams(searchParams);params.set('page', pageNumber.toString());return `${pathname}?${params.toString()}`;};
-
如果想让用户输入搜索的时候页签是第一个可以/app/ui/search.tsx加入
const params = new URLSearchParams(searchParams);//参数下边加入 params.set('page', '1');
12.增删改票据
首先学会使用from和action
例子:
// Server Component
export default function Page() {// Actionasync function create(formData: FormData) {'use server';// Logic to mutate data...}// Invoke the action using the "action" attributereturn <form action={create}>...</form>;
}
1. 创建票据
-
/dashboard/invoices/create/page.tsx
import Form from '@/app/ui/invoices/create-form'; import Breadcrumbs from '@/app/ui/invoices/breadcrumbs'; import { fetchCustomers } from '@/app/lib/data';export default async function Page() {const customers = await fetchCustomers();//breadcrumbs一个面包屑导航组件,帮助用户了解当前页面在网站中的位置。比如从“发票”页面到“创建发票”页面的导航//fetchCustomers() 异步获取客户数据,确保在渲染页面之前获取到相关的客户信息return (<main><Breadcrumbsbreadcrumbs={[{ label: 'Invoices', href: '/dashboard/invoices' },{label: 'Create Invoice',href: '/dashboard/invoices/create',active: true,},]}/><Form customers={customers} /></main>); }
创建Server Actions,新建/app/lib/actions.ts
'use server';export async function createInvoice(formData: FormData) {}
导入action,/app/ui/invoices/create-form.tsx
import { createInvoice } from '@/app/lib/actions';
//from改为<form action={createInvoice}>
在/app/lib/actions.ts中
//编辑异步函数,如果字段多用Object.fromEntries()
export async function createInvoice(formData: FormData) {const rawFormData = {customerId: formData.get('customerId'),amount: formData.get('amount'),status: formData.get('status'),};// Test it out:console.log(rawFormData);
}
在/app/lib/definitions.ts中有Invoice的定义
/app/lib/actions.ts中,使用验证库验证入参
'use server';import { z } from 'zod';const FormSchema = z.object({id: z.string(),customerId: z.string(),amount: z.coerce.number(),status: z.enum(['pending', 'paid']),date: z.string(),
});const CreateInvoice = FormSchema.omit({ id: true, date: true });export async function createInvoice(formData: FormData) {// ...
}
在/app/lib/definitions.ts中,开始验证
// ...
export async function createInvoice(formData: FormData) {const { customerId, amount, status } = CreateInvoice.parse({customerId: formData.get('customerId'),amount: formData.get('amount'),status: formData.get('status'),});
}
//console.log(rawFormData);
在/app/lib/definitions.ts中,用分消除浮点错误
//createInvoice函数最后一行加入
const amountInCents = amount * 100;
在/app/lib/definitions.ts中,创建日期
//createInvoice函数最后一行加入 const date = new Date().toISOString().split('T')[0];
在/app/lib/actions.ts中,插入数据库
import postgres from 'postgres';const sql = postgres(process.env.POSTGRES_URL!, { ssl: 'require' });
// ...//createInvoice函数最后一行加入
await sql`INSERT INTO invoices (customer_id, amount, status, date)VALUES (${customerId}, ${amountInCents}, ${status}, ${date})`;
在/app/lib/actions.ts中,由于要更新发票路由中显示的数据,因此需要清除此缓存并触发对服务器的新请求
import { revalidatePath } from 'next/cache';
//createInvoice函数最后一行加入
revalidatePath('/dashboard/invoices');
在/app/lib/actions.ts中,重定向回invoices页
import { redirect } from 'next/navigation';
//createInvoice函数最后一行加入redirect('/dashboard/invoices');
1.1测试添加
2. 更新票据
/app/ui/invoices/table.tsx中更新按钮会将id传入更新页面,InvoicesTable函数返回的td中的
/app/ui/invoices/buttons.tsx中,UpdateInvoice函数,Link修改
href={`/dashboard/invoices/${id}/edit`}
创建一个新的动态路由,/app/dashboard/invoices/[id]/edit/page.tsx
import Form from '@/app/ui/invoices/edit-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchCustomers } from '@/app/lib/data';export default async function Page() {return (<main><Breadcrumbsbreadcrumbs={[{ label: 'Invoices', href: '/dashboard/invoices' },{label: 'Edit Invoice',href: `/dashboard/invoices/${id}/edit`,active: true,},]}/><Form invoice={invoice} customers={customers} /></main>);
}
/app/dashboard/invoices/[id]/edit/page.tsx中,给page加上入参
export default async function Page(props: { params: Promise<{ id: string }> }) {const params = await props.params;const id = params.id;// ...
}
/dashboard/invoices/[id]/edit/page.tsx中,根据id获取数据
import { fetchInvoiceById, fetchCustomers } from '@/app/lib/data';//引入
//Page函数中获取数据
export default async function Page(props: { params: Promise<{ id: string }> }) {// ...const [invoice, customers] = await Promise.all([fetchInvoiceById(id),fetchCustomers(),]);// ...
访问: http://localhost:3000/dashboard/invoices,点击编辑就会展示数据,url改变为http://localhost:3000/dashboard/invoice/uuid/edit。
在/app/ui/invoices/edit-form.tsx中
//这样传值是错误的
//<form action={updateInvoice(id)}>
// ...
import { updateInvoice } from '@/app/lib/actions';export default function EditInvoiceForm({invoice,customers,
}: {invoice: InvoiceForm;customers: CustomerField[];
}) {const updateInvoiceWithId = updateInvoice.bind(null, invoice.id);return <form action={updateInvoiceWithId}>{/* ... */}</form>;
}
在/app/lib/actions.ts创建个UpdateInvoice操作
// Use Zod to update the expected types
const UpdateInvoice = FormSchema.omit({ id: true, date: true });// ...export async function updateInvoice(id: string, formData: FormData) {const { customerId, amount, status } = UpdateInvoice.parse({customerId: formData.get('customerId'),amount: formData.get('amount'),status: formData.get('status'),});const amountInCents = amount * 100;await sql`UPDATE invoicesSET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status}WHERE id = ${id}`;revalidatePath('/dashboard/invoices');redirect('/dashboard/invoices');
}
2.1测试更新
3. 删除票据
在/app/ui/invoices/buttons.tsx中,传入id
import { deleteInvoice } from '@/app/lib/actions';// ...export function DeleteInvoice({ id }: { id: string }) {const deleteInvoiceWithId = deleteInvoice.bind(null, id);return (<form action={deleteInvoiceWithId}><button type="submit" className="rounded-md border p-2 hover:bg-gray-100"><span className="sr-only">Delete</span><TrashIcon className="w-5" /></button></form>);
}
/app/lib/actions.ts创建一个deleteInvoice操作,因为没跳转页面不需要redirect
export async function deleteInvoice(id: string) {await sql`DELETE FROM invoices WHERE id = ${id}`;revalidatePath('/dashboard/invoices');
}
3.1测试删除
13.错误处理
1.添加try/catch
/app/lib/actions.ts中,sql一般需要加上
//createInvoice操作
try {await sql`INSERT INTO invoices (customer_id, amount, status, date)VALUES (${customerId}, ${amountInCents}, ${status}, ${date})`;} catch (error) {// We'll log the error to the console for nowconsole.error(error);}
//updateInvoice操作try {await sql`UPDATE invoicesSET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status}WHERE id = ${id}`;} catch (error) {// We'll log the error to the console for nowconsole.error(error);}
注意如何在try/catch块之外调用redirect。这是因为重定向通过抛出错误来工作,而错误将被catch块捕获。为了避免这种情况,可以在try/catch之后调用redirect。只有在try成功时才能访问重定向。举个例子(这个不需要复制到代码中,只是加深大家理解,这里项目中是会遇到的):
export async function createInvoice(formData: FormData) {let redirectUrl = ''; // 用于存储重定向的 URLtry {// 解析并验证表单数据const parsedData = CreateInvoice.parse(Object.fromEntries(formData));// 如果解析成功,进行发票创建的逻辑console.log('Parsed Data:', parsedData);// 模拟保存发票或其他处理逻辑// 假设这里是保存发票到数据库的代码// 如果一切顺利,设置成功重定向的 URLredirectUrl = '/success'; // 设定成功后的重定向页面} catch (error) {if (error instanceof z.ZodError) {// 处理验证错误console.error('验证失败:', error.errors);redirectUrl = '/error'; // 如果验证失败,重定向到错误页面} else {// 处理其他未预料的错误console.error('发生了一个意外错误:', error);redirectUrl = '/error'; // 遇到其他错误时重定向到错误页面}}// 在 try/catch 之后进行重定向if (redirectUrl) {redirect(redirectUrl); // 执行重定向}
}
/app/lib/actions.ts中deleteInvoice操作使用手动抛出异常的方式
export async function deleteInvoice(id: string) {throw new Error('Failed to Delete Invoice');// Unreachable code block//await sql`DELETE FROM invoices WHERE id = ${id}`;//revalidatePath('/dashboard/invoices');
}
1.1 测试删除失败
2.处理错误
使用error.tsx处理所有错误,创建个/dashboard/invoices/error.tsx,reset
按钮:
- 点击后会执行
reset()
,尝试重新渲染组件(在 Next.js 中,它会重新加载页面)。
'use client';import { useEffect } from 'react';export default function Error({error,reset,
}: {error: Error & { digest?: string };reset: () => void;
}) {useEffect(() => {// Optionally log the error to an error reporting serviceconsole.error(error);}, [error]);return (<main className="flex h-full flex-col items-center justify-center"><h2 className="text-center">Something went wrong!</h2><buttonclassName="mt-4 rounded-md bg-blue-500 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-400"onClick={// Attempt to recover by trying to re-render the invoices route() => reset()}>Try again</button></main>);
}
点击删除按钮,会有try again的按钮,点击try again会继续返回到删除按钮的列表页面,就是删除之前的页面。外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
使用notFound函数处理404错误,在/app/lib/data.ts中,加个打印看看uuid
export async function fetchInvoiceById(id: string) {try {// ...console.log(invoice); // Invoice is an empty array []return invoice[0];} catch (error) {console.error('Database Error:', error);throw new Error('Failed to fetch invoice.');}
}
访问http://localhost:3000/dashboard/invoices/2e94d1ed-d220-449f-9f11-f0bbceed9645/edit,这个时候还是返回try again
/dashboard/invoices/[id]/edit/page.tsx中,添加
import { notFound } from 'next/navigation';//加入
//Page函数使用Promise.all返回invoice后,判断数据是否存在,下边加上if (!invoice) {notFound();}
创建个/dashboard/invoices/[id]/edit/not-found.tsx页面,notFound优先于
error.tsx
import Link from 'next/link';
import { FaceFrownIcon } from '@heroicons/react/24/outline';export default function NotFound() {return (<main className="flex h-full flex-col items-center justify-center gap-2"><FaceFrownIcon className="w-10 text-gray-400" /><h2 className="text-xl font-semibold">404 Not Found</h2><p>Could not find the requested invoice.</p><Linkhref="/dashboard/invoices"className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-400">Go Back</Link></main>);
}
这个时候访问http://localhost:3000/dashboard/invoices/2e94d1ed-d220-449f-9f11-f0bbceed9645/edit,会返回notFound
把app/lib/actions.ts中deleteInvoice改回来吧,测试结束
错误处理文档链接
- Error Handling
error.js
API ReferencenotFound()
API Referencenot-found.js
API Reference
14.提高易用性
1. eslint-plugin-jsx-a11y
Next.js包含eslint-plugin-jsx-a11y,在ESLint配置中添加插件,以帮助及早发现可访问性问题
/package.json中增加
//scripts中加入
"lint": "next lint"
运行
pnpm lint
显示,不然你提交到vercel会部署失败
✔ No ESLint warnings or errors
验证ESlint,/app/ui/invoices/table.tsx中
<Imagesrc={invoice.image_url}className="rounded-full"width={28}height={28}alt={`${invoice.name}'s profile picture`} // 删除这行
/>
你会发现错误
./app/ui/invoices/table.tsx
88:23 Warning: Image elements must have an alt prop, either with meaningful text, or an empty string for decorative images. jsx-a11y/alt-text
2. 表单验证
在http://localhost:3000/dashboard/invoices/create页面,不填任何东西,点击提交按钮,请求服务端直接报错,
2.1客户端验证
我们可以在客户端加个required属性
/app/ui/invoices/create-form.tsx中,加入required,测试
<inputid="amount"name="amount"type="number"placeholder="Enter USD amount"className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"required
/>
验证后删除这个验证,开始测试服务端验证。
2.2服务端验证
也可以使用服务器端验证,通过验证服务器上的表单,可以解决:
- 确保您的数据在发送到数据库之前是预期的格式
- 降低恶意用户绕过客户端验证的风险
- 对于被认为有效的数据,有一个真实的来源
使用React的useActionState钩子来处理表单错误
useActionState钩子:
•有两个参数:(action, initialState)。
•返回两个值:[state, formAction] -表单状态,以及表单提交时调用的函数。
2.2.1添加
/app/ui/invoices/create-form.tsx中,
'use client';//标记为客户端// ...
import { useActionState } from 'react'; // 导入 useActionState hook(可能是自定义 hook)export default function Form({ customers }: { customers: CustomerField[] }) {// 使用 useActionState hook 来创建表单的状态和动作(创建发票的动作)const [state, formAction] = useActionState(createInvoice, initialState);// ...return <form action={formAction}>...</form>;// 渲染一个表单,表单的动作由 formAction 提供
}
/app/ui/invoices/create-form.tsx中,可以定义个initialState
// ...
import { createInvoice, State } from '@/app/lib/actions'; // 从指定路径导入 createInvoice 函数和 State 类型export default function Form({ customers }: { customers: CustomerField[] }) {// 定义初始状态const initialState: State = { message: null, errors: {} }; // ...
/app/lib/actions.ts中,修改FormSchema,加入异常返回参数
const FormSchema = z.object({id: z.string(),customerId: z.string({invalid_type_error: 'Please select a customer.',}),amount: z.coerce.number().gt(0, { message: 'Please enter an amount greater than $0.' }),status: z.enum(['pending', 'paid'], {invalid_type_error: 'Please select an invoice status.',}),date: z.string(),
});
在/app/lib/actions.ts中,加入状态
// ...
export type State = {errors?: {customerId?: string[];amount?: string[];status?: string[];};message?: string | null;
};export async function createInvoice(prevState: State, formData: FormData) {//使用状态// ...
}
/app/lib/actions.ts中,safeParse()将返回一个包含成功或错误字段的对象。这将有助于更优雅地处理验证,而无需将此逻辑放入try/catch块中。
//createInvoice函数中,CreateInvoice.parse改为const validatedFields = CreateInvoice.safeParse({
/app/lib/actions.ts中,validatedFields属性下边继续加验证
if (!validatedFields.success) {return {errors: validatedFields.error.flatten().fieldErrors,message: 'Missing Fields. Failed to Create Invoice.',};}
/app/lib/actions.ts中,验证后使用validatedFields.data获取数据
const { customerId, amount, status } = validatedFields.data;//结构数据
/app/ui/invoices/create-form.tsx中的select标签属性id为customer的加入属性
aria-describedby="customer-error"
/app/ui/invoices/create-form.tsx中,在
<div id="customer-error" aria-live="polite" aria-atomic="true">{state.errors?.customerId &&state.errors.customerId.map((error: string) => (<p className="mt-2 text-sm text-red-500" key={error}>{error}</p>))}</div>
/app/ui/invoices/create-form.tsx中的select标签属性id为amount的加入属性
aria-describedby="amount-error"
/app/ui/invoices/create-form.tsx中,在
<div id="amount-error" aria-live="polite" aria-atomic="true">{state.errors?.amount &&state.errors.amount.map((error: string) => (<p className="mt-2 text-sm text-red-500" key={error}>{error}</p>))}</div>
/app/ui/invoices/create-form.tsx中的select标签属性id为pending和paid都加入属性
aria-describedby="status-error"
/app/ui/invoices/create-form.tsx中,在fieldset标签中加入,展示错误提示
<div id="amount-error" aria-live="polite" aria-atomic="true">{state.errors?.amount &&state.errors.amount.map((error: string) => (<p className="mt-2 text-sm text-red-500" key={error}>{error}</p>))}</div>
3.2 编辑
/app/ui/invoices/edit-form.tsx也跟添加一样,代码如下
// ...
import { updateInvoice, State } from '@/app/lib/actions';
import { useActionState } from 'react';export default function EditInvoiceForm({invoice,customers,
}: {invoice: InvoiceForm;customers: CustomerField[];
}) {const initialState: State = { message: null, errors: {} };const updateInvoiceWithId = updateInvoice.bind(null, invoice.id);const [state, formAction] = useActionState(updateInvoiceWithId, initialState);return <form action={formAction}>{/* ... */}</form>;
}
/app/lib/actions.ts
export async function updateInvoice(id: string,prevState: State,formData: FormData,
) {const validatedFields = UpdateInvoice.safeParse({customerId: formData.get('customerId'),amount: formData.get('amount'),status: formData.get('status'),});if (!validatedFields.success) {return {errors: validatedFields.error.flatten().fieldErrors,message: 'Missing Fields. Failed to Update Invoice.',};}const { customerId, amount, status } = validatedFields.data;const amountInCents = amount * 100;try {await sql`UPDATE invoicesSET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status}WHERE id = ${id}`;} catch (error) {return { message: 'Database Error: Failed to Update Invoice.' };}revalidatePath('/dashboard/invoices');redirect('/dashboard/invoices');
}
在/app/ui/invoices/edit-form.tsx和添加一样的加法