Skip to content

Commit ba830f9

Browse files
committed
完成 Dashboard 和 Invoices 页面,包括字体,样式,布局,路由,动态路由,页面骨架,数据增删改查,URL参数,分页,错误处理,表单验证等相关功能
1 parent 299b5c2 commit ba830f9

22 files changed

+478
-42
lines changed

app/dashboard/(overview)/loading.tsx

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import DashboardSkeleton from '@/app/ui/skeletons';
2+
3+
export default function Loading() {
4+
return <DashboardSkeleton />;
5+
}

app/dashboard/(overview)/page.tsx

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Card } from '@/app/ui/dashboard/cards';
2+
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
3+
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
4+
import { lusitana } from '@/app/ui/fonts';
5+
import CardWrapper from '@/app/ui/dashboard/cards';
6+
import { Suspense } from 'react';
7+
import { CardsSkeleton, RevenueChartSkeleton, LatestInvoicesSkeleton } from '@/app/ui/skeletons';
8+
9+
export default async function Page() {
10+
11+
return (
12+
<main>
13+
<h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
14+
Dashboard
15+
</h1>
16+
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
17+
<Suspense fallback={<CardsSkeleton />}>
18+
<CardWrapper />
19+
</Suspense>
20+
{/* <Card title="Collected" value={totalPaidInvoices} type="collected" />
21+
<Card title="Pending" value={totalPendingInvoices} type="pending" />
22+
<Card title="Total Invoices" value={numberOfInvoices} type="invoices" />
23+
<Card title="Total Customers" value={numberOfCustomers} type="customers" /> */}
24+
</div>
25+
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
26+
<Suspense fallback={<RevenueChartSkeleton />}>
27+
<RevenueChart />
28+
</Suspense>
29+
<Suspense fallback={<LatestInvoicesSkeleton />}>
30+
<LatestInvoices />
31+
</Suspense>
32+
</div>
33+
</main>
34+
);
35+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import Link from 'next/link';
2+
import { FaceFrownIcon } from '@heroicons/react/24/outline';
3+
4+
export default function NotFound() {
5+
return (
6+
<main className="flex h-full flex-col items-center justify-center gap-2">
7+
<FaceFrownIcon className="w-10 text-gray-400" />
8+
<h2 className="text-xl font-semibold">404 Not Found</h2>
9+
<p>Could not find the requested invoice.</p>
10+
<Link
11+
href="/dashboard/invoices"
12+
className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-400"
13+
>
14+
Go Back
15+
</Link>
16+
</main>
17+
);
18+
}
+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import Form from '@/app/ui/invoices/edit-form';
2+
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
3+
import { fetchInvoiceById, fetchCustomers } from '@/app/lib/data';
4+
import { notFound } from 'next/navigation';
5+
6+
export default async function Page(props: { params: Promise<{id: string}> }) {
7+
const params = await props.params;
8+
const id = params.id;
9+
10+
// 使用Promise.all并行获取发票和客户:
11+
const [invoice, customers] = await Promise.all([
12+
fetchInvoiceById(id),
13+
fetchCustomers(),
14+
]);
15+
16+
// 如果发票不存在,您可以使用条件调用notFound:
17+
if (!invoice) {
18+
notFound();
19+
}
20+
21+
return (
22+
<main>
23+
<Breadcrumbs
24+
breadcrumbs={[
25+
{ label: 'Invoices', href: '/dashboard/invoices' },
26+
{
27+
label: 'Edit Invoice',
28+
href: `/dashboard/invoices/${id}/edit`,
29+
active: true,
30+
},
31+
]}
32+
/>
33+
<Form invoice={invoice} customers={customers} />
34+
</main>
35+
);
36+
}
+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import Form from '@/app/ui/invoices/create-form';
2+
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
3+
import { fetchCustomers } from '@/app/lib/data';
4+
5+
export default async function Page() {
6+
const customers = await fetchCustomers();
7+
8+
return (
9+
<main>
10+
<Breadcrumbs
11+
breadcrumbs={[
12+
{ label: 'Invoices', href: '/dashboard/invoices' },
13+
{
14+
label: 'Create Invoice',
15+
href: '/dashboard/invoices/create',
16+
active: true,
17+
},
18+
]}
19+
/>
20+
<Form customers={customers} />
21+
</main>
22+
);
23+
}

app/dashboard/invoices/error.tsx

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
'use client';
2+
3+
import { useEffect } from 'react';
4+
5+
export default function Error({
6+
error,
7+
reset,
8+
}: {
9+
error: Error & { digest?: string };
10+
reset: () => void;
11+
}) {
12+
useEffect(() => {
13+
// Optionally log the error to an error reporting service
14+
console.error(error);
15+
}, [error]);
16+
17+
return (
18+
<main className="flex h-full flex-col items-center justify-center">
19+
<h2 className="text-center">Something went wrong!</h2>
20+
<button
21+
className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-400"
22+
onClick={
23+
// Attempt to recover by trying to re-render the invoices route
24+
() => reset()
25+
}
26+
>
27+
Try again
28+
</button>
29+
</main>
30+
);
31+
}

app/dashboard/invoices/page.tsx

+37-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,38 @@
1-
export default function Page() {
2-
return <p>Invoices Page</p>;
1+
import Pagination from '@/app/ui/invoices/pagination';
2+
import Search from '@/app/ui/search';
3+
import Table from '@/app/ui/invoices/table';
4+
import { CreateInvoice } from '@/app/ui/invoices/buttons';
5+
import { lusitana } from '@/app/ui/fonts';
6+
import { InvoicesTableSkeleton } from '@/app/ui/skeletons';
7+
import { Suspense } from 'react';
8+
import { fetchInvoicesPages } from '@/app/lib/data';
9+
10+
export default async function Page(props: {
11+
searchParams?: Promise<{
12+
query?: string;
13+
page?: string;
14+
}>
15+
}) {
16+
const searchParams = await props.searchParams;
17+
const query = searchParams?.query || '';
18+
const currentPage = Number(searchParams?.page) || 1;
19+
const totalPages = await fetchInvoicesPages(query);
20+
21+
return (
22+
<div className="w-full">
23+
<div className="flex w-full items-center justify-between">
24+
<h1 className={`${lusitana.className} text-2xl`}>Invoices</h1>
25+
</div>
26+
<div className="mt-4 flex items-center justify-between gap-2 md:mt-8">
27+
<Search placeholder="Search invoices..." />
28+
<CreateInvoice />
29+
</div>
30+
<Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}>
31+
<Table query={query} currentPage={currentPage} />
32+
</Suspense>
33+
<div className="mt-5 flex w-full justify-center">
34+
<Pagination totalPages={totalPages} />
35+
</div>
36+
</div>
37+
);
338
}

app/dashboard/layout.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import SideNav from '@/app/ui/dashboard/sidenav';
2-
2+
3+
// export const experimental_ppr = true;
4+
35
export default function Layout({ children }: { children: React.ReactNode }) {
46
return (
57
<div className="flex h-screen flex-col md:flex-row md:overflow-hidden">

app/dashboard/page.tsx

-3
This file was deleted.

app/lib/actions.ts

+119
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
'use server';
2+
3+
// 使用静态类型推断进行 TypeScript 优先模式验证 https://zod.dev/
4+
import { z } from 'zod';
5+
import { sql } from '@vercel/postgres';
6+
import { revalidatePath } from 'next/cache';
7+
import { redirect } from 'next/navigation';
8+
9+
const FormSchema = z.object({
10+
id: z.string(),
11+
customerId: z.string({
12+
invalid_type_error: 'Please select a customer.',
13+
}),
14+
amount: z.coerce.number().gt(0, { message: 'Please enter an amount greater than $0.' }),
15+
status: z.enum(['pending', 'paid'], {
16+
invalid_type_error: 'Please select an invoice status.',
17+
}),
18+
date: z.string(),
19+
});
20+
21+
const CreateInvoice = FormSchema.omit({ id: true, date: true });
22+
const UpdateInvoice = FormSchema.omit({ id: true, date: true });
23+
24+
// 错误状态类型定义
25+
export type State = {
26+
errors?: {
27+
customerId?: string[];
28+
amount?: string[];
29+
status?: string[];
30+
};
31+
message?: string | null;
32+
};
33+
34+
// 创建发票
35+
export async function createInvoice(prevState: State, formData: FormData) {
36+
37+
// const rawFormData1 = Object.fromEntries(formData.entries())
38+
// console.log(rawFormData1);
39+
40+
// Validate form using Zod
41+
// Zod parse()函数更改为safeParse()而不是parse()
42+
// safeParse()将返回一个包含success或error字段的对象。这将有助于更优雅地处理验证,而无需将此逻辑放入try/catch块中。
43+
const validatedFields = CreateInvoice.safeParse({
44+
customerId: formData.get('customerId'),
45+
amount: formData.get('amount'),
46+
status: formData.get('status'),
47+
});
48+
49+
console.log(validatedFields)
50+
// If form validation fails, return errors early. Otherwise, continue.
51+
// 在将信息发送到数据库之前,请检查表单字段是否已使用条件正确验证:
52+
if (!validatedFields.success) {
53+
return {
54+
errors: validatedFields.error.flatten().fieldErrors,
55+
message: 'Missing Fields. Failed to Create Invoice.',
56+
};
57+
}
58+
// Prepare data for insertion into the database
59+
const { customerId, amount, status } = validatedFields.data;
60+
// 通常最好的做法是在数据库中存储以美分为单位的货币值,以消除 JavaScript 浮点错误并确保更高的准确性。让我们将金额转换为美分:
61+
const amountInCents = amount * 100;
62+
// 我们为发票的创建日期创建一个格式为“YYYY-MM-DD”的新日期:
63+
const date = new Date().toISOString().split('T')[0];
64+
try {
65+
// Insert data into the database
66+
await sql`INSERT INTO invoices (customer_id, amount, status, date) VALUES (${customerId}, ${amountInCents}, ${status}, ${date})`;
67+
} catch (error) {
68+
return {
69+
// If a database error occurs, return a more specific error.
70+
message: 'Database Error: Failed to Create Invoice.',
71+
};
72+
}
73+
// 由于您要更新发票路由中显示的数据,因此您希望清除此缓存并向服务器触发新请求。您可以使用 Next.js 中的revalidatePath函数来执行此操作:
74+
// 数据库更新后, /dashboard/invoices路径将重新验证,并从服务器获取新数据。
75+
revalidatePath('/dashboard/invoices');
76+
77+
// 此时,您还希望将用户重定向回/dashboard/invoices页面。您可以使用 Next.js 中的redirect功能来执行此操作:
78+
redirect('/dashboard/invoices');
79+
}
80+
81+
// 更新发票
82+
export async function updateInvoice(id: string, prevState: State, formData: FormData) {
83+
const validatedFields = UpdateInvoice.safeParse({
84+
customerId: formData.get('customerId'),
85+
amount: formData.get('amount'),
86+
status: formData.get('status'),
87+
});
88+
89+
if (!validatedFields.success) {
90+
return {
91+
errors: validatedFields.error.flatten().fieldErrors,
92+
message: 'Missing Fields. Failed to Update Invoice.',
93+
};
94+
}
95+
96+
const { customerId, amount, status } = validatedFields.data;
97+
const amountInCents = amount * 100;
98+
99+
try {
100+
await sql`UPDATE invoices SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status} WHERE id = ${id}`;
101+
} catch (error) {
102+
return { message: 'Database Error: Failed to Update Invoice.' };
103+
}
104+
revalidatePath('/dashboard/invoices');
105+
redirect('/dashboard/invoices');
106+
}
107+
108+
// 根据主键删除发票
109+
export async function deleteInvoice(id: string) {
110+
throw new Error('Failed to Delete Invoice');
111+
112+
try {
113+
await sql`DELETE FROM invoices WHERE id = ${id}`;
114+
revalidatePath('/dashboard/invoices');
115+
return { message: 'Deleted Invoice.' };
116+
} catch (error) {
117+
return { message: 'Database Error: Failed to Delete Invoice.' };
118+
}
119+
}

app/lib/data.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,12 @@ export async function fetchRevenue() {
1414
// Artificially delay a response for demo purposes.
1515
// Don't do this in production :)
1616

17-
// console.log('Fetching revenue data...');
18-
// await new Promise((resolve) => setTimeout(resolve, 3000));
17+
console.log('Fetching revenue data...');
18+
await new Promise((resolve) => setTimeout(resolve, 3000));
1919

2020
const data = await sql<Revenue>`SELECT * FROM revenue`;
2121

22-
// console.log('Data fetch completed after 3 seconds.');
22+
console.log('Data fetch completed after 3 seconds.');
2323

2424
return data.rows;
2525
} catch (error) {
@@ -158,6 +158,7 @@ export async function fetchInvoiceById(id: string) {
158158
amount: invoice.amount / 100,
159159
}));
160160

161+
console.log(invoice); // Invoice is an empty array []
161162
return invoice[0];
162163
} catch (error) {
163164
console.error('Database Error:', error);

app/ui/dashboard/cards.tsx

+6-2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
InboxIcon,
66
} from '@heroicons/react/24/outline';
77
import { lusitana } from '@/app/ui/fonts';
8+
import { fetchCardData } from '@/app/lib/data';
89

910
const iconMap = {
1011
collected: BanknotesIcon,
@@ -14,18 +15,21 @@ const iconMap = {
1415
};
1516

1617
export default async function CardWrapper() {
18+
19+
const { numberOfCustomers, numberOfInvoices, totalPaidInvoices, totalPendingInvoices } = await fetchCardData();
20+
1721
return (
1822
<>
1923
{/* NOTE: Uncomment this code in Chapter 9 */}
2024

21-
{/* <Card title="Collected" value={totalPaidInvoices} type="collected" />
25+
<Card title="Collected" value={totalPaidInvoices} type="collected" />
2226
<Card title="Pending" value={totalPendingInvoices} type="pending" />
2327
<Card title="Total Invoices" value={numberOfInvoices} type="invoices" />
2428
<Card
2529
title="Total Customers"
2630
value={numberOfCustomers}
2731
type="customers"
28-
/> */}
32+
/>
2933
</>
3034
);
3135
}

0 commit comments

Comments
 (0)