跳至主要內容

官网课程笔记

小企鹅笔记前端NextjsNextjs大约 16 分钟约 4859 字...

官网课程笔记

链接:https://nextjs.org/learn/dashboard-appopen in new window

Getting Started

export type和interface有什么区别

/app/lib/definitions.ts

export type Invoice = {
  id: string;
  customer_id: string;
  amount: number;
  date: string;
  // In TypeScript, this is called a string union type.
  // It means that the "status" property can only be one of the two strings: 'pending' or 'paid'.
  status: 'pending' | 'paid';
};

在 TypeScript 中,export typeinterface 都可以用来定义自定义类型,但它们有一些区别:

  1. 语法差异:
    • export type: 使用 export type 时,你定义了一个新的类型别名。它类似于给一个现有类型起一个新的名字。
    • interface: 使用 interface 时,你定义了一个新的类型。它描述了一个对象的结构,包括属性、方法以及可能的方法参数和返回值。
  2. 继承与实现:
    • export type 不支持继承或实现其他类型或接口。
    • interface 支持继承其他接口,以及实现其他类和接口。
  3. 可读性:
    • interface 通常更加直观,因为它们主要用于描述对象的形状和行为。
    • export type 用于创建新的名称以引用现有类型,可能更适合于复杂的类型定义和联合类型。
  4. 使用场景:
    • 当你需要描述对象的结构和行为时,使用 interface 是一个很好的选择。
    • 当你需要创建复杂的联合类型、交叉类型或给已有类型起一个新的名字时,使用 export type 更合适。

总的来说,interface 更加适用于对象的描述,而 export type 更加适用于类型别名的创建和复杂类型的定义。选择哪个取决于你的具体需求和偏好。

CSS Styling

提示

global.css 全局css样式

这段代码是使用Tailwind CSS来定义基础样式、组件和实用工具,并针对输入类型为数字的表单元素进行样式设置,主要是为了移除浏览器默认的数字输入框样式和微调按钮。

具体来说:

  • @tailwind base; 导入了基础样式,包括重置浏览器默认样式等。
  • @tailwind components; 导入了组件样式,其中包括一些常用组件的样式,如按钮、表单元素等。
  • @tailwind utilities; 导入了实用工具,提供了一系列的实用类,可用于快速添加样式。

接着,代码针对 input[type='number'] 这类表单元素进行了自定义样式设置:

  • -moz-appearance: textfield; appearance: textfield; 用于移除 Firefox 浏览器默认的数字输入框样式,使其显示为文本输入框。
  • ::-webkit-inner-spin-button::-webkit-outer-spin-button 是针对 Webkit 内核浏览器(如Chrome和Safari)的微调按钮样式,通过 -webkit-appearance: none;margin: 0; 来移除默认样式,使微调按钮不显示。
/* 导入基础样式 */
@tailwind base;
/* 导入组件样式 */
@tailwind components;
/* 导入实用工具 */
@tailwind utilities;

/* 自定义样式:移除数字输入框的默认样式 */
input[type='number'] {
  /* 移除 Firefox 浏览器默认样式 */
  -moz-appearance: textfield;
  /* 移除 Webkit 内核浏览器默认样式 */
  appearance: textfield;
}

/* 移除 Webkit 内核浏览器的微调按钮样式 */
input[type='number']::-webkit-inner-spin-button {
  -webkit-appearance: none;
  margin: 0;
}

/* 移除 Webkit 内核浏览器的微调按钮样式 */
input[type='number']::-webkit-outer-spin-button {
  -webkit-appearance: none;
  margin: 0;
}

提示

最好将 global.css 放在根布局 /app/layout.tsx

提示

/app/ui 下创建 home.module.css ,可以创建全局唯一CSS

.shape {
  height: 0;
  width: 0;
  border-bottom: 30px solid black;
  border-left: 20px solid transparent;
  border-right: 20px solid transparent;
}
import styles from '@/app/ui/home.module.css';
<div className={styles.shape} />;

提示

clsx 可以为某一元素进行条件CSS,例如,InvoiceStatus 会根据状态 status 的不同,显示不同的样式

import clsx from 'clsx';
 
export default function InvoiceStatus({ status }: { status: string }) {
  return (
    <span
      className={clsx(
        'inline-flex items-center rounded-full px-2 py-1 text-sm',
        {
          'bg-gray-100 text-gray-500': status === 'pending',
          'bg-green-500 text-white': status === 'paid',
        },
      )}
    >
    // ...
)}

不用 clsx 实现

import React from 'react';

function InvoiceStatus({ status }) {
  let statusClass = '';

  if (status === 'paid') {
    statusClass = 'text-green-500'; // 如果状态是'paid',设置类名为'green'
  } else if (status === 'pending') {
    statusClass = 'text-gray-500'; // 如果状态是'pending',设置类名为'gray'
  }

  return (
    <div className={statusClass}>
      {/* Your component content here */}
      <p>{status}</p>
    </div>
  );
}

export default InvoiceStatus;

Optimizing Fonts and Images

Fonts

**累积布局位移(Cumulative Layout Shift,CLS)**是一个用来衡量网页在加载过程中视觉稳定性的指标。它衡量的是在页面生命周期内所有不受用户意愿控制的布局位移的总和。换句话说,CLS 衡量了页面加载过程中由于内容动态变化而导致的元素位移的累积效果。

通俗说,就是用户点开一个网页,到网页加载完成,呈现的布局最好是不动的,看图

提示

创建一个 /app/ui/fonts.ts 来定义全局字体

import { Inter } from 'next/font/google';
 
export const inter = Inter({ subsets: ['latin'] });
import '@/app/ui/global.css';
import { inter } from '@/app/ui/fonts';
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={`${inter.className} antialiased`}>{children}</body>
    </html>
  );
}
  • ({ ... }): 这表示一个对象的解构,这个对象有一个名为 children 的属性。

  • children: 这个属性是从传入组件的参数中解构出来的。在 React 中,children 是特殊的 prop,它表示组件的子元素。

  • : { ... }: 这表示一个对象的类型声明,指定了 children 属性的类型。

  • children: React.ReactNode;: 这里指定了 children 属性的类型为 React.ReactNodeReact.ReactNode 是一个 TypeScript 类型,用于表示可以在 React 组件中作为子元素的任何类型,包括 JSX 元素、字符串、数字、布尔值、数组等。

antialiased

antialiased 是一个 CSS 类,通常用于改善文本在浏览器中的呈现,使其更加平滑和清晰。

在大多数浏览器中,文本的呈现可能会出现锯齿或不清晰的情况,特别是在某些字体和字号的情况下。这种锯齿效果是由于浏览器在将文本渲染到屏幕上时,像素点的排列方式导致的。为了减少这种锯齿效果,可以使用抗锯齿(antialiasing)技术。

antialiased 类通常会在文本元素上应用抗锯齿效果,使文本看起来更加平滑和清晰。这种效果在特定的字体和字号下尤其明显,能够显著提升网页的视觉质量和用户体验。

在上述代码中,antialiased 类被应用于 <body> 元素上,意味着整个页面中的文本都会受到抗锯齿效果的影响,使页面中的文本更加平滑和清晰。

提示

试着添加新字体 Lusitana

import { Inter, Lusitana } from 'next/font/google';
 
export const inter = Inter({ subsets: ['latin'] });
 
export const lusitana = Lusitana({
  weight: ['400', '700'],
  subsets: ['latin'],
});

这里的 weight: ['400', '700'] 指定了字体的权重。在 Web 开发中,字体的权重通常用数字来表示,例如:

  • 400: 表示普通(regular)或正常(normal)权重的字体。
  • 700: 表示粗体(bold)权重的字体。

在 CSS 中,这些数字通常与 font-weight 属性一起使用,例如:

cssCopy codefont-weight: 400; /* 正常字体 */
font-weight: 700; /* 粗体字体 */

所以在这里,weight: ['400', '700'] 表示这个字体对象包含两种不同权重的字体,一种是普通权重(400),另一种是粗体权重(700)。

提示

接下来就可以删除 <AcmeLogo /> 的注释了,因为这个组件之前用到了 Lusitana ,所以之前会报错

images

Nextjs对图片做了很多优化,具体可见:https://nextjs.org/learn/dashboard-app/optimizing-fonts-images#why-optimize-imagesopen in new window

使用 import Image from 'next/image'; 可导入Nextjs的image组件

import AcmeLogo from '@/app/ui/acme-logo';
import { ArrowRightIcon } from '@heroicons/react/24/outline';
import Link from 'next/link';
import { lusitana } from '@/app/ui/fonts';
import Image from 'next/image';
 
export default function Page() {
  return (
    // ...
    <div className="flex items-center justify-center p-6 md:w-3/5 md:px-28 md:py-12">
      {/* Add Hero Images Here */}
      <Image
        src="/hero-desktop.png"
        width={1000}
        height={760}
        className="hidden md:block"
        alt="Screenshots of the dashboard project showing desktop version"
      />
      <Image
        src="/hero-mobile.png"
        width={560}
        height={620}
        className="block md:hidden"
        alt="Screenshot of the dashboard project showing mobile version"
      />
    </div>
    //...
  );
}

::: important

  • 这里设置的宽高分别为1000和760,这个宽高是原始图片的大小,有必要去设置它,避免布局的偏移
  • className="hidden md:block" 表示当为移动端时这个图片隐藏,当为桌面端时图片固定显示判断移动端还是桌面端是通过屏幕的尺寸
  • 利用第2条可以实现移动端和桌面端显示不同更适配的图片

:::

Creating Layouts and Pages

Pages

只有 page.tsx 可以被访问到,所以其他组件可以放在对应平级的目录下

Layouts

dashboard 这个目录创建一个根布局

import SideNav from '@/app/ui/dashboard/sidenav';
 
export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <div className="flex h-screen flex-col md:flex-row md:overflow-hidden">
      <div className="w-full flex-none md:w-64">
        <SideNav />
      </div>
      <div className="flex-grow p-6 md:overflow-y-auto md:p-12">{children}</div>
    </div>
  );
}

children 表示一个 React.ReactNode ,可以类比Vue中的 <slot \>

children 可以是当前目录下的 page 、子目录下的 pagelayout

提示

可以 RootLayout 中可以对 <html><body>设置全局的元数据,例如,以下字体的效果是全局的

<body className={`${inter.className} antialiased`}>{children}</body>

将以下代码的 <a> 换成 next/link 中的 <Link /> ,可以实现同一路径下的 client-side navigationopen in new window,从而避免全局刷新

import {
  UserGroupIcon,
  HomeIcon,
  DocumentDuplicateIcon,
} from '@heroicons/react/24/outline';
import Link from 'next/link';
 
// ...
 
export default function NavLinks() {
  return (
    <>
      {links.map((link) => {
        const LinkIcon = link.icon;
        return (
          <a
            key={link.name}
            href={link.href}
            className="flex h-[48px] grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3"
          >
            <LinkIcon className="w-6" />
            <p className="hidden md:block">{link.name}</p>
          </a>
        );
      })}
    </>
  );
}

在侧边栏显示当前选中页面

  • 可以使用Nextjs提供的Hook usePathname()open in new window 获取当前页面路径
  • 用Hook必须使用 'use client' 标记该文件仅在客户端渲染
  • 通过 clsx 插件最后来完成这一过程
'use client';
 
import {
  UserGroupIcon,
  HomeIcon,
  DocumentDuplicateIcon,
} from '@heroicons/react/24/outline';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import clsx from 'clsx';
 
// ...
 
export default function NavLinks() {
  const pathname = usePathname();
 
  return (
    <>
      {links.map((link) => {
        const LinkIcon = link.icon;
        return (
          <Link
            key={link.name}
            href={link.href}
            className={clsx(
              'flex h-[48px] grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3',
              {
                'bg-sky-100 text-blue-600': pathname === link.href,
              },
            )}
          >
            <LinkIcon className="w-6" />
            <p className="hidden md:block">{link.name}</p>
          </Link>
        );
      })}
    </>
  );
}

Setting Up Your Database

这一章介绍了如何在 vercel 中创建一个SQL数据库

https://nextjs.org/learn/dashboard-app/setting-up-your-databaseopen in new window

Fetching Data

React Server Components

看教程即可,https://nextjs.org/learn/dashboard-app/fetching-data#fetching-data-for-revenuechartopen in new window

  • data.ts 中可以请求 vercel 中SQL数据库的数据,并进行处理

    可以用 Promise.all()open in new windowPromise.allSettled()open in new window 同时请求

    export async function fetchCardData() {
      try {
        const invoiceCountPromise = sql`SELECT COUNT(*) FROM invoices`;
        const customerCountPromise = sql`SELECT COUNT(*) FROM customers`;
        const invoiceStatusPromise = sql`SELECT
             SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS "paid",
             SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END) AS "pending"
             FROM invoices`;
     
        const data = await Promise.all([
          invoiceCountPromise,
          customerCountPromise,
          invoiceStatusPromise,
        ]);
        // ...
      }
    }
    
  • 然后调用 data.ts 中的函数,进行组件的渲染

import { Card } from '@/app/ui/dashboard/cards';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import { lusitana } from '@/app/ui/fonts';
import {
  fetchRevenue,
  fetchLatestInvoices,
  fetchCardData,
} from '@/app/lib/data';
 
export default async function Page() {
  const revenue = await fetchRevenue();
  const latestInvoices = await fetchLatestInvoices();
  const {
    numberOfInvoices,
    numberOfCustomers,
    totalPaidInvoices,
    totalPendingInvoices,
  } = await fetchCardData();

  return (
    <main>
      <h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
        Dashboard
      </h1>
      <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
        <Card title="Collected" value={totalPaidInvoices} type="collected" />
        <Card title="Pending" value={totalPendingInvoices} type="pending" />
        <Card title="Total Invoices" value={numberOfInvoices} type="invoices" />
        <Card
          title="Total Customers"
          value={numberOfCustomers}
          type="customers"
        />
      </div>
      <div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
        <RevenueChart revenue={revenue}  />
        <LatestInvoices latestInvoices={latestInvoices} />
      </div>
    </main>
  );
}

提示

这里面有一些 tailwind css 对与不同端适配的布局,例如

对于四个 Card,先设为了 grid 样式

  • 默认为从上到下

    default
    default
  • sm 尺寸时,两个一排

    sm
    sm
  • lg 尺寸时,两四个一排

    lg
    lg

Static and Dynamic Rendering

在上文中,使用 @vercel/postgres 没有缓存管理

可以用 next/cache 中的 unstable_noStore 使得数据是动态的(不缓存数据)

// ...
import { unstable_noStore as noStore } from 'next/cache';
 
export async function fetchRevenue() {
  // Add noStore() here to prevent the response from being cached.
  // This is equivalent to in fetch(..., {cache: 'no-store'}).
  noStore();
 
  // ...
}

Streaming

Streaming 的意思是将传输的数据或者是每一个小组件分块传输,这样的话就能先显示已经送到的数据,从而提升用户打开网页的体验:

loading.tsx

对于页面,可以在当前目录使用 loading.tsx

  • <SideNav> 是静态的,所以会直接显示
  • 使用组件 <DashboardSkeleton /> 可以使得收集的骨架先没被加载出来
import DashboardSkeleton from '@/app/ui/skeletons';
 
export default function Loading() {
  return <DashboardSkeleton />;
}

有一个问题就是由于 page.txs 和其子文件夹的页面是同级的。所以其子文件夹页面也会使用这个组件

所以可以创建一个overview文件夹,使得loading界面只被用于当前目录

提示

可以创建多个这样的文件夹 (**) ,来实现不同的页面效果

< Suspense >

对于组件,可以使用<Suspense> ,例如

// 使用前
<RevenueChart revenue={revenue}  />

// 使用后
<Suspense fallback={<RevenueChartSkeleton />}>
    <RevenueChart />
</Suspense>

其中,RevenueChartSkeletonRevenueChart 的骨架图

此外,需要将组件设为 async , 因为需要在组件中获取数据

Adding Search and Pagination

捕捉输入

'use client';
 
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
 
export default function Search({ placeholder }: { placeholder: string }) {
  function handleSearch(term: string) {
    console.log(term);
  }
 
  return (
    <div className="relative flex flex-1 flex-shrink-0">
      <label htmlFor="search" className="sr-only">
        Search
      </label>
      <input
        className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
        placeholder={placeholder}
        onChange={(e) => {
          handleSearch(e.target.value);
        }}
      />
      <MagnifyingGlassIcon className="absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
    </div>
  );
}

类比Vue的 v-model ,Nextjs使用 onChange

更新URL

通过 useSearchParams, usePathname, useRouter 可以实现URL根据搜索框内的内容变化

'use client';
 
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams, usePathname, useRouter } from 'next/navigation';
 
export default function Search() {
  const searchParams = useSearchParams();
  const pathname = usePathname();
  const { replace } = useRouter();
 
  function handleSearch(term: string) {
    const params = new URLSearchParams(searchParams);
    if (term) {
      params.set('query', term);
    } else {
      params.delete('query');
    }
    replace(`${pathname}?${params.toString()}`);
  }
}

设置默认值

<input
  className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
  placeholder={placeholder}
  onChange={(e) => {
    handleSearch(e.target.value);
  }}
  defaultValue={searchParams.get('query')?.toString()}
/>

提示

这里使用的是 defaultValue 而不是 value

value 用到的是React的state,而这里用了URL来保存Query,所以这样也是可以的

添加Table

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 { Suspense } from 'react';
import { InvoicesTableSkeleton } from '@/app/ui/skeletons';
 
export default async function Page({
  searchParams,
}: {
  searchParams?: {
    query?: string;
    page?: string;
  };
}) {
  const query = searchParams?.query || '';
  const currentPage = Number(searchParams?.page) || 1;
 
  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>
  );
}
  • key={query + currentPage}

    key 属性是 React 中用于识别组件的唯一性的特殊属性。当你在列表中渲染具有相同类型的组件时,React 需要一种方式来区分它们。这个 key 属性就是为了解决这个问题的。在这个特定的例子中,key 属性使用了 querycurrentPage 的值,这意味着每当 querycurrentPage 的值发生变化时,React 将会认为这是一个新的 <Suspense> 组件,而不是简单地重用之前的组件。

  • fallback={<InvoicesTableSkeleton />}

    fallback 属性指定了在等待异步加载完成时要显示的备用内容。在这里,当异步操作还未完成时,它将显示 <InvoicesTableSkeleton /> 组件。这个组件可能是一个骨架屏或者是一个简单的加载动画,用于提供用户反馈,告诉他们数据正在加载中。

  • searchParams

​ 这个属性也是Nextjs内置的

提示

When to use the useSearchParams() hook vs. the searchParams prop?

You might have noticed you used two different ways to extract search params. Whether you use one or the other depends on whether you're working on the client or the server.

  • <Search> is a Client Component, so you used the useSearchParams() hook to access the params from the client.
  • <Table> is a Server Component that fetches its own data, so you can pass the searchParams prop from the page to the component.

As a general rule, if you want to read the params from the client, use the useSearchParams() hook as this avoids having to go back to the server.

Debouncing

可以注意到,以上搜索是每次输入有变化就触发一次,可以用 'use-debounce',设置函数触发的间隔

const handleSearch = useDebouncedCallback((term) => {
  console.log(`Searching... ${term}`);
 
  const params = new URLSearchParams(searchParams);
  if (term) {
    params.set('query', term);
  } else {
    params.delete('query');
  }
  replace(`${pathname}?${params.toString()}`);
}, 300);

Pagination

接下来加上分页,同样是用构造URL的方式

'use client';

import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import Link from 'next/link';
import { generatePagination } from '@/app/lib/utils';
import { usePathname, useSearchParams } from 'next/navigation';

export default function Pagination({ totalPages }: { totalPages: number }) {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const currentPage = Number(searchParams.get('page')) || 1;

  const createPageURL = (pageNumber: number | string) => {
    const params = new URLSearchParams(searchParams);
    params.set('page', pageNumber.toString());
    return `${pathname}?${params.toString()}`;
  };

  // NOTE: comment in this code when you get to this point in the course

  const allPages = generatePagination(currentPage, totalPages);

  return (
    <>
      {/* NOTE: comment in this code when you get to this point in the course */}

      <div className="inline-flex">
        <PaginationArrow
          direction="left"
          href={createPageURL(currentPage - 1)}
          isDisabled={currentPage <= 1}
        />

        <div className="flex -space-x-px">
          {allPages.map((page, index) => {
            let position: 'first' | 'last' | 'single' | 'middle' | undefined;

            if (index === 0) position = 'first';
            if (index === allPages.length - 1) position = 'last';
            if (allPages.length === 1) position = 'single';
            if (page === '...') position = 'middle';

            return (
              <PaginationNumber
                key={page}
                href={createPageURL(page)}
                page={page}
                position={position}
                isActive={currentPage === page}
              />
            );
          })}
        </div>

        <PaginationArrow
          direction="right"
          href={createPageURL(currentPage + 1)}
          isDisabled={currentPage >= totalPages}
        />
      </div>
    </>
  );
}

Mutating Data

Create

  • 点击 Create 按钮,跳转到路径 /dashboard/invoices/create 对应的创建页面

  • 页面中包含一个表单 <Form customers={customers} />

  • 表单绑定了一个 <form action={createInvoice}> ,并包含以下内容

    • 一个选择 customers 的下拉框
    • 一个类型为数字的 Input 输入框
    • 一个单选框 Radio
  • 点击 Submit 后,触发 createInvoice

    'use server';
    
    // 校验数据类型的库
    import { z } from 'zod'; 
    import { sql } from '@vercel/postgres';
    
    // 刷新指定路由页面的数据
    import { revalidatePath } from 'next/cache';
    
    // 跳转到指定路由页面
    import { redirect } from 'next/navigation';
    
    // 校验数据类型
    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) {
        const { customerId, amount, status } = CreateInvoice.parse({
        customerId: formData.get('customerId'),
        amount: formData.get('amount'),
        status: formData.get('status'),
      });
    
      // 用分为单位存储金钱
      const amountInCents = amount * 100;
      const date = new Date().toISOString().split('T')[0];
    
      await sql`
        INSERT INTO invoices (customer_id, amount, status, date)
        VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
      `;
    
      revalidatePath('/dashboard/invoices');
      redirect('/dashboard/invoices');
    }
    

Upate

  • Table 中有编辑按钮,点击会跳转到 /dashboard/invoices/${id}/edit

    • ${id} 对应文件系统的文件夹 [id]
    • 采用 { params }: { params: { id: string } } 接受ID,并查询详细信息
    import Form from '@/app/ui/invoices/edit-form';
    import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
    import { fetchInvoiceById, fetchCustomers } from '@/app/lib/data';
     
    export default async function Page({ params }: { params: { id: string } }) {
      const id = params.id;
      
      const [invoice, customers] = await Promise.all([
        fetchInvoiceById(id),
        fetchCustomers(),
      ]);
      
        return (
        <main>
          <Breadcrumbs
            breadcrumbs={[
              { label: 'Invoices', href: '/dashboard/invoices' },
              {
                label: 'Edit Invoice',
                href: `/dashboard/invoices/${id}/edit`,
                active: true,
              },
            ]}
          />
          <Form invoice={invoice} customers={customers} />
        </main>
      );
    }
    
  • 编辑界面有对应的表单组件 <Form invoice={invoice} customers={customers} />

  • 表单同样需要绑定 <form action={updateInvoiceWithId}>

    • 由于是更新操作,所以需要传入ID才能更新
    • 不能使用 <form action={updateInvoice(id)}> 这样传入,而是使用 bind
      • const updateInvoiceWithId = updateInvoice.bind(null, invoice.id);

Delete

Delete 没有界面,所以直接在表格界面的 Button 中绑定表单即可

import { deleteInvoice } from '@/app/lib/actions';
 
// ...
 
export function DeleteInvoice({ id }: { id: string }) {
  const deleteInvoiceWithId = deleteInvoice.bind(null, id);
 
  return (
    <form action={deleteInvoiceWithId}>
      <button className="rounded-md border p-2 hover:bg-gray-100">
        <span className="sr-only">Delete</span>
        <TrashIcon className="w-4" />
      </button>
    </form>
  );
}
export async function deleteInvoice(id: string) {
  await sql`DELETE FROM invoices WHERE id = ${id}`;
  revalidatePath('/dashboard/invoices');
}

Handling Errors

Error

在目录下创建 error.tsx,使用 try-catch 捕获错误时,都会自动跳转到这个界面

Not Found

在目录下创建 not-found.tsx,当使用 import { notFound } from 'next/navigation';notFound () 时,会跳转到这个界面

上次编辑于:
你认为这篇文章怎么样?
  • 0
  • 0
  • 0
  • 0
  • 0
  • 0
评论
  • 按正序
  • 按倒序
  • 按热度