react课程23-项目收尾

Last updated on October 18, 2024 am

本节实现:DarkMode、DashBoard建立、Error Bundary、项目部署

一、实现Dark Mode

(1)useLocalStorage

这个自定义钩子类似useState的效果,不同的是它是从LocalStorage中来读取值。首先value从LocalStorage通过key值读取,如果没有就返回初始状态。每次value或者key改变,useEffect 会被触发,将当前的 value 保存到 localStorage 中。这确保了状态的任何更新都会同步到 localStorage

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { useState, useEffect } from 'react';

export function useLocalStorageState(initialState, key) {
const [value, setValue] = useState(function () {
const storedValue = localStorage.getItem(key);
return storedValue ? JSON.parse(storedValue) : initialState;
});

useEffect(
function () {
localStorage.setItem(key, JSON.stringify(value));
},
[value, key]
);

return [value, setValue];
}

使用 useLocalStorageState 的优势

  • 持久化存储:通过 localStorage,状态在用户刷新页面或关闭浏览器后仍然保留。
  • 用户偏好:能够保存用户的界面偏好设置(如 dark modelight mode),提升用户体验。
  • 易于管理:与 localStorage 同步的状态,使得跨页面的状态管理变得简单。

(2)contextAPI

darkmode的状态被设置为全球状态,以加强页面的一致性。因此用到了contextAPI,复习。

  1. 使用createContext新建context
  2. 建立Provider函数,返回context.provider,传递value
  3. 建立useContext()钩子,方便读取context内容。
  4. 导出provider和useContext
  5. 用Provider整个包裹住App

这里的Provider放置一个useEffect钩子,使得每次isDarkMode切换的时候,都相应地添加或者移除light或者dark类,实现改变亮暗风格的效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const DarkModeContext = createContext();

function DarkModeProvider({ children }) {
const [isDarkMode, setIsDarkMode] = useLocalStorageState(false, 'isDarkMode');

useEffect(
function () {
if (isDarkMode) {
document.documentElement.classList.add('dark-mode');
document.documentElement.classList.remove('light-mode');
} else {
document.documentElement.classList.add('light-mode');
document.documentElement.classList.remove('dark-mode');
}
},
[isDarkMode]
);

function toggleDarkMode() {
setIsDarkMode((isDark) => !isDark);
}

return (
<DarkModeContext.Provider value={{ isDarkMode, toggleDarkMode }}>
{children}
</DarkModeContext.Provider>
);
}

function useDarkMode() {
const context = useContext(DarkModeContext);
if (context === undefined)
throw new Error('DarkModeContext was used outside of DarkModeProvider');
return context;
}

export { DarkModeProvider, useDarkMode };

二、构建Dashboard

(1)apiBooking

1、获得指定日期后的预定数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
export async function getBookingsAfterDate(date) {
const { data, error } = await supabase
.from('bookings')
.select('created_at, totalPrice, extrasPrice')
.gte('created_at', date)
.lte('created_at', getToday({ end: true }));

if (error) {
console.error(error);
throw new Error('Bookings could not get loaded');
}

return data;
}
2、获得指定日期后的入住数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export async function getStaysAfterDate(date) {
const { data, error } = await supabase
.from('bookings')
// .select('*')
.select('*, guests(fullName)')
.gte('startDate', date)
.lte('startDate', getToday());

if (error) {
console.error(error);
throw new Error('Bookings could not get loaded');
}

return data;
}
3、获取当天的活动
  1. bookings 表中查询数据。
  2. 选择所有字段(*)以及关联的 guests 表中的特定字段(fullNamenationalitycountryFlag)。
  3. 通过 or 条件筛选出符合以下任一条件的预订:
    • 状态为 unconfirmedstartDate 等于今天的日期。
    • 状态为 checked-inendDate 等于今天的日期。
  4. 根据 created_at 字段对查询结果进行排序。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export async function getStaysTodayActivity() {
const { data, error } = await supabase
.from('bookings')
.select('*, guests(fullName, nationality, countryFlag)')
.or(
`and(status.eq.unconfirmed,startDate.eq.${getToday()}),and(status.eq.checked-in,endDate.eq.${getToday()})`
)
.order('created_at');

// Equivalent to this. But by querying this, we only download the data we actually need, otherwise we would need ALL bookings ever created
// (stay.status === 'unconfirmed' && isToday(new Date(stay.startDate))) ||
// (stay.status === 'checked-in' && isToday(new Date(stay.endDate)))

if (error) {
console.error(error);
throw new Error('Bookings could not get loaded');
}
return data;
}

(2)使用api中的函数

useRecentStays

通过params得到filter中的数据,并计算实际入住的数量。

subDays函数计算指定日期之前的日期,toISOString()将其转化为supabase能接受的IOS形式。

useRecentBookings和它很像,不赘述;useTodayActivity较为简单,也不赘述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { useQuery } from '@tanstack/react-query';
import { subDays } from 'date-fns';
import { useSearchParams } from 'react-router-dom';
import { getStaysAfterDate } from '../../services/apiBookings';

export function useRecentStays() {
const [searchParams] = useSearchParams();
const numDays = !searchParams.get('last')
? 7
: Number(searchParams.get('last'));

const queryDate = subDays(new Date(), numDays).toISOString();

const { isLoading, data: stays } = useQuery({
queryFn: () => getStaysAfterDate(queryDate),
queryKey: ['stays', `last-${numDays}`],
});

const confirmedStays = stays?.filter(
(stay) => stay.status === 'checked-in' || stay.status === 'checked-out'
);

return { isLoading, stays, confirmedStays };
}

(3)构建stats

image-20240927155756049

用到了可重用的组件stat。四个放置在stats组件中,较为简单不赘述。

(4)使用Recharts Library构建图表

npm i recharts@2


1、销售量线状图(可切换明暗模式)

(太多参数了,看得我眼花缭乱😵‍💫😵‍💫😵‍💫)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
function SalesChart({ bookings, numDays }) {
const { isDarkMode } = useDarkMode();

const allDates = eachDayOfInterval({
start: subDays(new Date(), numDays - 1),
end: new Date(),
});

const data = allDates.map((date) => {
return {
label: format(date, 'MMM dd'),
totalSales: bookings
.filter((booking) => isSameDay(date, new Date(booking.created_at)))
.reduce((acc, cur) => acc + cur.totalPrice, 0),
extrasSales: bookings
.filter((booking) => isSameDay(date, new Date(booking.created_at)))
.reduce((acc, cur) => acc + cur.extrasPrice, 0),
};
});

const colors = isDarkMode
? {
totalSales: { stroke: '#4f46e5', fill: '#4f46e5' },
extrasSales: { stroke: '#22c55e', fill: '#22c55e' },
text: '#e5e7eb',
background: '#18212f',
}
: {
totalSales: { stroke: '#4f46e5', fill: '#c7d2fe' },
extrasSales: { stroke: '#16a34a', fill: '#dcfce7' },
text: '#374151',
background: '#fff',
};

return (
<StyledSalesChart>
<Heading as="h2">
Sales from {format(allDates.at(0), 'MMM dd yyyy')} &mdash;{' '}
{format(allDates.at(-1), 'MMM dd yyyy')}{' '}
</Heading>

<ResponsiveContainer height={300} width="100%">
<AreaChart data={data}>
<XAxis
dataKey="label"
tick={{ fill: colors.text }}
tickLine={{ stroke: colors.text }}
/>
<YAxis
unit="$"
tick={{ fill: colors.text }}
tickLine={{ stroke: colors.text }}
/>
<CartesianGrid strokeDasharray="4" />
<Tooltip contentStyle={{ backgroundColor: colors.background }} />
<Area
dataKey="totalSales"
type="monotone"
stroke={colors.totalSales.stroke}
fill={colors.totalSales.fill}
strokeWidth={2}
name="Total sales"
unit="$"
/>
<Area
dataKey="extrasSales"
type="monotone"
stroke={colors.extrasSales.stroke}
fill={colors.extrasSales.fill}
strokeWidth={2}
name="Extras sales"
unit="$"
/>
</AreaChart>
</ResponsiveContainer>
</StyledSalesChart>
);
}

image-20240927164947789

2、构建入住时间统计饼图
image-20240927192105306

(5)构建今日活动

image-20240927192152464
1、整体布局组件

加载状态

  • 如果 isLoading 为 false,则表示数据已加载。

    • 活动列表:

      如果 activities 数组长度大于 0,渲染 TodayList,并使用 map 方法遍历每个活动,渲染 TodayItem 组件,传入活动数据。

    • 无活动提示

      如果 activities 数组长度为 0,显示 “No activity today…” 的提示。

  • 如果 isLoadingtrue,则显示一个加载中的 Spinner 组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function TodayActivity() {
const { isLoading, activities } = useTodayActivity();

return (
<StyledToday>
<Row type="horizontal">
<Heading as="h2">Today</Heading>
</Row>

{!isLoading ? (
activities?.length > 0 ? (
<TodayList>
{activities.map((activity) => (
<TodayItem activity={activity} key={activity.id} />
))}
</TodayList>
) : (
<NoActivity>No activity today...</NoActivity>
)
) : (
<Spinner />
)}
</StyledToday>
);
}
2、每个活动的条目组件

alt 属性是图像的替代文本。在图像无法显示时,替代文本会被显示出来,此外,它对使用屏幕阅读器的用户也很重要。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function TodayItem({ activity }) {
const { id, status, guests, numNights } = activity;

return (
<StyledTodayItem>
{status === 'unconfirmed' && <Tag type="green">Arriving</Tag>}
{status === 'checked-in' && <Tag type="blue">Departing</Tag>}

<Flag src={guests.countryFlag} alt={`Flag of ${guests.country}`} />

<Guest>{guests.fullName}</Guest>
<div>{numNights} nights</div>

{status === 'unconfirmed' && (
<Button
size="small"
variation="primary"
as={Link}
to={`/checkin/${id}`}
>
Check in
</Button>
)}

{status === 'checked-in' && <CheckoutButton bookingId={id} />}
</StyledTodayItem>
);
}

三、Error Boundaries

npm i react-error-boundary


(1)为App提供错误边界处理

1
2
3
4
5
6
7
8
9
10
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<ErrorBoundary
FallbackComponent={ErrorFallback}
onReset={() => window.location.replace('/')}
>
<App />
</ErrorBoundary>
</React.StrictMode>
);

ErrorBoundary 是一个错误边界组件,它捕获渲染树中的 JavaScript 错误,防止整个应用崩溃。错误边界只捕获渲染生命周期构造函数中的错误,不捕获事件处理器中的错误。

**FallbackComponent={ErrorFallback}**:

  • FallbackComponent 指定了当应用中发生错误时要显示的组件。ErrorFallback 是一个自定义组件,用于在错误发生时为用户展示友好的信息。

**onReset={() => window.location.replace('/')}**:

  • onResetErrorBoundary 组件的一个回调函数,当用户尝试重置错误时触发。在这个例子中,当 ErrorFallback 中的“重试”按钮被点击时,页面将通过 window.location.replace('/') 重新加载到应用的根路径(/)。

(2)构建错误发生后的组件界面(ErrorFallBack)

由于这个组件是脱离组件树的,所以要放置GlobalStyle,否则样式不起作用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function ErrorFallback({ error, resetErrorBoundary }) {
return (
<>
<GlogalStyles />
<StyledErrorFallback>
<Box>
<Heading as="h1">Someting went wrong 😢</Heading>
<p>{error.message}</p>
<Button size="large" onClick={resetErrorBoundary}>
Try again
</Button>
</Box>
</StyledErrorFallback>
</>
);
}

image-20240927195221035

四、BUG修整

(1)toggle的矛盾

之前我们把检测是否在窗体外点击的默认情况下,将 listenCapturing 设置为 true,表示事件是在捕获阶段被处理的。但是这会造成一个问题是,点击某个窗体的toggle,再次点击无法实现关闭的功能。一下是两点需要修改的地方。

image-20240927200325930

(2)不存在的booking订单

当我们将bookingId随便修改一个值之后,会到达ErrorFallBack界面,显示booking是undifined,但是觉得这个错误描述的不好,于是修改一下错误界面:

if (!booking) return <Empty resourceName="booking" />;

(3)明暗模式的默认值

在我们的电脑的控制台输入这行代码,会显示true or false。true代表电脑默认是使用的dark模式,false代表light,比如我的电脑就是。于是我们期望可以按照不同用户的电脑需求来使得他们刚进入APP时显示相应的mode。

window.matchMedia('(prefers-color-scheme: dark)').matches

所以可以把isDarkMode的默认值就设为上述代码。

1
2
3
4
const [isDarkMode, setIsDarkMode] = useLocalStorageState(
window.matchMedia('(prefers-color-scheme: dark)').matches,
'isDarkMode'
);

(4)功能展望

创建booking、编辑booking,创建餐厅,结账时到发票界面、可以选择打印。

五、部署APP

npm run build 会生成一个dist包,这就是我们需要上传的包


(1)上传到Netlify

1、完善软件包

首先,我们需要在dist文件夹中新建一个文件:Netlify.toml,内容是:

1
2
3
4
[[redirects]]
from = "/*"
to = "/index.html"
status = 200

然后再把它添加到项目的根目录下,这样如果需要修改项目再部署,这时候这个文件会自动添加到dist文件夹中,就不需要手动创建了。这是重定向配置,确保当用户直接访问某个页面(例如 /dashboard/about)时,Netlify 会加载 index.html,从而让前端路由器处理这些路径。

2、登陆上传项目

首先注册登陆,在主页选择manually部署,将整个dist文件夹上传。

image-20240927204130502

上传后点击下图按钮,可以进入我们的APP界面,其中的URL如果分享给别人,他们就可以打开并看到我们的项目。

image-20240927204432552

其中URL里的项目名可以自己修改。

image-20240927204716005

(2)部署到Github仓库

首先创建一个仓库,最好设为私有。生成一个token(但是我部署项目的时候没用到,不知道为什么)

在我们的项目中,先输入git init

image-20240927210019652

然后会看到所有的文件都变成了绿色,表示已经被git控制,其中除开public和src文件夹外的其他文件在最右边都有一个字母U,表示未被跟踪,终端输入git status也可以查看这一点。

第一次上传/每次修改文件后上传

输入:git add -A 将所有的变更(包括新增、修改和删除的文件)添加到暂存区

输入:git commit -m "your message" 提交变更,确保所有工作都被保存。

输入:git remote add origin "你的github仓库地址".git 将本地仓库与远程 GitHub 仓库关联起来(只有第一次上传的时候需要)

输入:git push -u origin main 将本地的 main 分支推送到远程的 origin 仓库,并设置上游(upstream)跟踪关系。这意味着以后你可以只使用 git pushgit pull 来更新该分支,而不需要每次都指定远程仓库和分支。

(3)部署到Vercel

image-20240927213206860

不知道为啥报错了,不管了!

六、完结撒花

🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸


react课程23-项目收尾
http://example.com/2024/09/27/react课程23-项目收尾/
Author
Yaodeer
Posted on
September 27, 2024
Licensed under