介绍
在本教程中,我们将使用Refine框架构建一个HR管理应用,并将其部署到DigitalOcean应用平台。
在本教程结束时,我们将拥有一个包括以下内容的HR管理应用:
- 登录页面:允许用户以经理或员工身份登录。经理可以访问
休假
和请求
页面,而员工只能访问休假
页面。 - 休假页面:允许员工请求、查看和取消他们的休假。同时,经理可以分配新的休假。
- 请求页面:仅限HR经理访问,用于批准或拒绝休假请求。
注意:您可以从这个GitHub代码库获取我们将在本教程中构建的应用的完整源代码。
在这个过程中,我们将使用:
- Rest API: 用于获取和更新数据。 Refine内置数据提供程序包和REST API,但您也可以构建自己的以满足特定要求。在本指南中,我们将使用NestJs CRUD作为我们的后端服务,以及@refinedev/nestjsx-crud包作为我们的数据提供程序。
- Material UI: 我们将使用它来构建UI组件,并根据我们自己的设计进行完全定制。Refine内置支持Material UI,但您可以使用任何喜欢的UI库。
一旦我们构建了应用程序,我们将使用DigitalOcean的App平台将其上线,该平台可轻松设置、启动和扩展应用程序和静态网站。您只需简单地指向GitHub存储库部署代码,让App平台来管理基础设施、应用程序运行时和依赖项。
先决条件
- Node.js的本地开发环境。您可以按照如何安装Node.js并创建本地开发环境。
- 一些关于 React 和 TypeScript 的基础知识。您可以参考 如何在 React.js 中编码 和 与 React 一起使用 TypeScript 系列。
- 一个 GitHub 账号
- 一个 DigitalOcean 账号
什么是 Refine?
Refine 是一个开源的 React 元框架,用于构建复杂的 B2B Web 应用程序,主要针对数据管理的使用案例,如内部工具、管理面板和仪表板。它通过提供一组钩子和组件来改善开发过程,为开发人员提供更好的工作流程。
它为企业级应用程序提供功能完整、可投入生产的功能,以简化诸如状态和数据管理、身份验证和访问控制等付费任务。这使开发人员能够专注于应用程序的核心,而无需处理许多令人困惑的实现细节。
步骤 1 — 设置项目
我们将使用 npm create refine-app
命令以交互方式初始化项目。
npm create refine-app@latest
在提示时选择以下选项:
✔ Choose a project template · Vite
✔ What would you like to name your project?: · hr-app
✔ Choose your backend service to connect: · nestjsx-crud
✔ Do you want to use a UI Framework?: · Material UI
✔ Do you want to add example pages?: · No
✔ Do you need any Authentication logic?: · None
✔ Choose a package manager: · npm
设置完成后,导航到项目文件夹并使用以下命令启动您的应用:
npm run dev
在浏览器中打开 http://localhost:5173
以查看应用。
准备项目
现在我们已经设置好项目,让我们对项目结构进行一些更改,并删除不必要的文件。
首先,安装第三方依赖:
@mui/x-date-pickers
、@mui/x-date-pickers-pro
:这些是 Material UI 的日期选择器组件。我们将使用它们来选择请假请求的日期范围。react-hot-toast
:一个简约的 React 吐司库。我们将用它来显示成功和错误消息。react-infinite-scroll-component
:一个使无限滚动变得简单的 React 组件。我们将用它在用户向下滚动页面以查看更多请求时加载更多请假请求。dayjs
:一个轻量级日期库,用于解析、验证、处理和格式化日期。vite-tsconfig-paths
:一个 Vite 插件,允许您在 Vite 项目中使用 TypeScript 路径别名。
npm install @mui/x-date-pickers @mui/x-date-pickers-pro dayjs react-hot-toast react-infinite-scroll-component
npm install --save-dev vite-tsconfig-paths
在安装依赖项后,更新 vite.config.ts
和 tsconfig.json
以使用 vite-tsconfig-paths
插件。这使得在 Vite 项目中启用 TypeScript 路径别名,允许使用 @
别名进行导入。
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [tsconfigPaths({ root: __dirname }), react()],
});
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": "./src",
"paths": {
"@/*": ["./*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
接下来,让我们删除不必要的文件和文件夹:
src/contexts
:这个文件夹包含一个文件,即ColorModeContext
。它处理应用的深色/浅色模式。我们在本教程中不会使用它。src/components
:这个文件夹包含<Header />
组件。我们在本教程中将使用自定义的头部组件。
rm -rf src/contexts src/components
在删除文件和文件夹后,App.tsx
会出现错误,我们将在接下来的步骤中修复它。
在整个教程中,我们将涵盖核心页面和组件的编码。因此,从 GitHub 仓库 获取必要的文件和文件夹。通过这些文件,我们将为我们的 HR 管理应用程序构建一个基本结构。
- icons:图标文件夹,包含所有应用图标。
- types:
index.ts
:应用类型。
- 工具:
constants.ts
: 应用常量。axios.ts
: 用于API请求的Axios实例,处理访问令牌、刷新令牌和错误。init-dayjs.ts
: 初始化Day.js并加载所需插件。
- 提供者:
- 组件:
复制文件和文件夹后,文件结构应如下所示:
└── 📁src
└── 📁components
└── 📁frame
└── 📁input
└── 📁layout
└── 📁header
└── 📁page-header
└── 📁sider
└── 📁loading-overlay
└── 📁modal
└── 📁icons
└── 📁providers
└── 📁access-control
└── 📁auth-provider
└── 📁notification-provider
└── 📁query-client
└── 📁theme-provider
└── 📁types
└── 📁utilities
└── App.tsx
└── index.tsx
└── vite-env.d.ts
接下来,更新 App.tsx
文件以包含必要的提供者和组件。
import { Refine } from '@refinedev/core'
import { DevtoolsProvider, DevtoolsPanel } from '@refinedev/devtools'
import dataProvider from '@refinedev/nestjsx-crud'
import routerProvider, { UnsavedChangesNotifier, DocumentTitleHandler } from '@refinedev/react-router-v6'
import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import { Layout } from '@/components/layout'
import { ThemeProvider } from '@/providers/theme-provider'
import { authProvider } from '@/providers/auth-provider'
import { accessControlProvider } from '@/providers/access-control'
import { useNotificationProvider } from '@/providers/notification-provider'
import { queryClient } from '@/providers/query-client'
import { BASE_URL } from '@/utilities/constants'
import { axiosInstance } from '@/utilities/axios'
import { Role } from './types'
import '@/utilities/init-dayjs'
function App() {
return (
<BrowserRouter>
<ThemeProvider>
<DevtoolsProvider>
<Refine
authProvider={authProvider}
routerProvider={routerProvider}
dataProvider={dataProvider(BASE_URL, axiosInstance)}
notificationProvider={useNotificationProvider}
resources={[
{
name: 'employee',
meta: {
scope: Role.EMPLOYEE,
},
},
{
name: 'manager',
meta: {
scope: Role.MANAGER,
},
},
]}
accessControlProvider={accessControlProvider}
options={{
reactQuery: {
clientConfig: queryClient,
},
syncWithLocation: true,
warnWhenUnsavedChanges: true,
useNewQueryKeys: true,
}}>
<Routes>
<Route
element={
<Layout>
<Outlet />
</Layout>
}>
<Route index element={<h1>Hello World</h1>} />
</Route>
</Routes>
<UnsavedChangesNotifier />
<DocumentTitleHandler />
<Toaster position='bottom-right' reverseOrder={false} />
<DevtoolsPanel />
</Refine>
</DevtoolsProvider>
</ThemeProvider>
</BrowserRouter>
)
}
export default App
让我们分解一下我们对 App.tsx
文件所做的重要更改:
<Refine />
: 来自@refinedev/core
的核心组件,包裹整个应用程序以提供数据获取、状态管理和其他功能。<DevtoolsProvider />
和<DevtoolsPanel />
: 用于调试和开发目的。<ThemeProvider />
:在整个应用中应用自定义主题。- 初始化 Day.js:用于日期和时间操作。
- 资源: 一个数组,指定 Refine 将获取的数据实体(
employee
和manager
)。我们使用父资源和子资源来组织数据和管理权限。每个资源都有一个scope
,定义用户角色,控制对应用不同部分的访问。 - queryClient:一个自定义查询客户端,完全控制和定制数据获取。
- syncWithLocation:启用应用状态(过滤器、排序器、分页等)与 URL 的同步。
- warnWhenUnsavedChanges:当用户尝试从具有未保存更改的页面导航离开时显示警告。
<布局 />
:一个自定义布局组件,用于包裹应用内容。它包含了头部、侧边栏和主要内容区域。我们将在接下来的步骤中解释这个组件。
现在,我们准备开始构建人力资源管理应用程序。
步骤 2— 自定义和样式化
仔细查看theme-provider
。我们已经大量定制了Material UI主题,以匹配人力资源管理应用程序的设计,创建了两个主题,一个用于经理,一个用于员工,以不同的颜色区分它们。
另外,我们还将Inter作为应用程序的自定义字体添加进去。要安装它,您需要将以下行添加到index.html
文件中:
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<^>
<link <^ />
<^>
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"
rel="stylesheet" /> <^>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="refine | Build your React-based CRUD applications, without constraints."
/>
<meta
data-rh="true"
property="og:image"
content="https://refine.dev/img/refine_social.png"
/>
<meta
data-rh="true"
name="twitter:image"
content="https://refine.dev/img/refine_social.png"
/>
<title>
Refine - Build your React-based CRUD applications, without constraints.
</title>
</head>
检查自定义 <Layout />
组件
在上一步中,我们向应用程序添加了一个自定义布局组件。通常,我们可以使用 UI 框架的默认布局,但我们想展示如何进行自定义。
布局组件包含头部、侧边栏和主内容区域。它使用 <ThemedLayoutV2 />
作为基础,并进行了定制以匹配人力资源管理应用程序的设计。
<Sider />
侧边栏包含应用程序标志和导航链接。在移动设备上,它是一个可折叠的侧边栏,当用户点击菜单图标时打开。导航链接使用useMenu
hook从Refine准备,并根据用户的角色使用<CanAccess />
组件渲染。
<UserSelect />
挂载在侧边栏上,显示已登录用户的头像和姓名。点击时,它会打开一个包含用户详细信息和注销按钮的弹出窗口。用户可以通过从下拉菜单中选择不同的角色来切换角色。此组件允许通过切换具有不同角色的用户进行测试。
<Header />
在桌面设备上不渲染任何内容。在移动设备上,它显示应用程序标志和一个菜单图标以打开侧边栏。页眉是固定的,始终显示在页面顶部。
<PageHeader />
它显示页面标题和导航按钮。页面标题通过useResource
钩子自动生成,该钩子从Refine上下文中获取资源名称。这使我们能够在整个应用程序中共享相同的样式和布局。
步骤3 — 实施身份验证和授权
在此步骤中,我们将为我们的HR管理应用程序实施身份验证和授权逻辑。这将作为企业应用程序中访问控制的一个很好的示例。
当用户以经理身份登录时,他们将能够看到Time Off
和Requests
页面。如果他们以员工身份登录,他们只能看到Time Off
页面。经理可以在Requests
页面上批准或拒绝休假申请。
员工可以在休假
页面请求休假并查看他们的历史记录。为了实现这一点,我们将使用Refine的authProvider
和accessControlProvider
功能。
认证
在Refine中,认证由authProvider
处理。它允许您为应用程序定义认证逻辑。在上一步中,我们已经从GitHub存储库中复制了authProvider
并将其作为prop给<Refine />
组件。我们将使用以下钩子和组件来根据用户是否已登录来控制应用程序的行为。
useLogin
:提供一个用于登录用户的mutate
函数。useLogout
:提供一个用于登出用户的mutate
函数。useIsAuthenticated
: 一个钩子,返回一个布尔值,指示用户是否经过身份验证。<Authenticated />
: 一个组件,仅在用户经过身份验证时才渲染其子组件。
Authorization
在 Refine 中,授权由 accessControlProvider
处理。它允许您定义用户角色和权限,并根据用户的角色控制对应用程序不同部分的访问。在上一步中,我们已经从 GitHub 仓库中复制了 accessControlProvider
并作为属性提供给 <Refine />
组件。让我们仔细看看 accessControlProvider
,以了解它是如何工作的。
import type { AccessControlBindings } from "@refinedev/core";
import { Role } from "@/types";
export const accessControlProvider: AccessControlBindings = {
options: {
queryOptions: {
keepPreviousData: true,
},
buttons: {
hideIfUnauthorized: true,
},
},
can: async ({ params, action }) => {
const user = JSON.parse(localStorage.getItem("user") || "{}");
if (!user) return { can: false };
const scope = params?.resource?.meta?.scope;
// 如果资源没有范围,则无法访问
if (!scope) return { can: false };
if (user.role === Role.MANAGER) {
return {
can: true,
};
}
if (action === "manager") {
return {
can: user.role === Role.MANAGER,
};
}
if (action === "employee") {
return {
can: user.role === Role.EMPLOYEE,
};
}
// 用户只能在其角色与资源范围匹配时访问资源
return {
can: user.role === scope,
};
},
};
在我们的应用中,我们有两个角色:MANAGER
和 EMPLOYEE
。
经理可以访问 Requests
页面,而员工只能访问 Time Off
页面。accessControlProvider
会检查用户的角色和资源范围,以确定用户是否可以访问该资源。如果用户的角色与资源范围匹配,他们可以访问该资源。否则,他们将被拒绝访问。我们将使用 useCan
钩子和 <CanAccess />
组件来根据用户的角色控制我们应用的行为。
设置登录页面
在上一步中,我们将 authProvider
添加到 <Refine />
组件中。authProvider
负责处理身份验证。
首先,我们需要获取图像。我们将使用这些图像作为登录页面的背景图像。在 public
文件夹中创建一个名为 images
的新文件夹,并从 GitHub 仓库 获取图像。
在获取到图像后,让我们在 src/pages/login
文件夹中创建一个名为 index.tsx
的新文件,并添加以下代码:
import { useState } from "react";
import { useLogin } from "@refinedev/core";
import {
Avatar,
Box,
Button,
Divider,
MenuItem,
Select,
Typography,
} from "@mui/material";
import { HrLogo } from "@/icons";
export const PageLogin = () => {
const [selectedEmail, setSelectedEmail] = useState<string>(
mockUsers.managers[0].email,
);
const { mutate: login } = useLogin();
return (
<Box
sx={{
position: "relative",
background:
"linear-gradient(180deg, #7DE8CD 0%, #C6ECD9 24.5%, #5CD6D6 100%)",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
height: "100dvh",
}}
>
<Box
sx={{
zIndex: 2,
background: "white",
width: "328px",
padding: "24px",
borderRadius: "36px",
display: "flex",
flexDirection: "column",
gap: "24px",
}}
>
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "16px",
}}
>
<HrLogo />
<Typography variant="body1" fontWeight={600}>
Welcome to RefineHR
</Typography>
</Box>
<Divider />
<Box sx={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<Typography variant="caption" color="text.secondary">
Select user
</Typography>
<Select
size="small"
value={selectedEmail}
sx={{
height: "40px",
borderRadius: "12px",
"& .MuiOutlinedInput-notchedOutline": {
borderWidth: "1px !important",
borderColor: (theme) => `${theme.palette.divider} !important`,
},
}}
MenuProps={{
sx: {
"& .MuiList-root": {
paddingBottom: "0px",
},
"& .MuiPaper-root": {
border: (theme) => `1px solid ${theme.palette.divider}`,
borderRadius: "12px",
boxShadow: "none",
},
},
}}
>
<Typography
variant="caption"
textTransform="uppercase"
color="text.secondary"
sx={{
paddingLeft: "12px",
paddingBottom: "8px",
display: "block",
}}
>
Managers
</Typography>
{mockUsers.managers.map((user) => (
<MenuItem
key={user.email}
value={user.email}
onClick={() => setSelectedEmail(user.email)}
>
<Box sx={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Avatar
src={user.avatarUrl}
alt={`${user.firstName} ${user.lastName}`}
sx={{ width: "24px", height: "24px" }}
/>
<Typography
noWrap
variant="caption"
sx={{ display: "flex", alignItems: "center" }}
>
{`${user.firstName} ${user.lastName}`}
</Typography>
</Box>
</MenuItem>
))}
<Divider />
<Typography
variant="caption"
textTransform="uppercase"
color="text.secondary"
sx={{
paddingLeft: "12px",
paddingBottom: "8px",
display: "block",
}}
>
Employees
</Typography>
{mockUsers.employees.map((user) => (
<MenuItem
key={user.email}
value={user.email}
onClick={() => setSelectedEmail(user.email)}
>
<Box sx={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Avatar
src={user.avatarUrl}
alt={`${user.firstName} ${user.lastName}`}
sx={{ width: "24px", height: "24px" }}
/>
<Typography
noWrap
variant="caption"
sx={{ display: "flex", alignItems: "center" }}
>
{`${user.firstName} ${user.lastName}`}
</Typography>
</Box>
</MenuItem>
))}
</Select>
</Box>
<Button
variant="contained"
sx={{
borderRadius: "12px",
height: "40px",
width: "100%",
color: "white",
backgroundColor: (theme) => theme.palette.grey[900],
}}
onClick={() => {
login({ email: selectedEmail });
}}
>
Sign in
</Button>
</Box>
<Box
sx={{
zIndex: 1,
width: {
xs: "240px",
sm: "370px",
md: "556px",
},
height: {
xs: "352px",
sm: "554px",
md: "816px",
},
position: "absolute",
left: "0px",
bottom: "0px",
}}
>
<img
src="/images/login-left.png"
alt="flowers"
width="100%"
height="100%"
/>
</Box>
<Box
sx={{
zIndex: 1,
width: {
xs: "320px",
sm: "480px",
md: "596px",
},
height: {
xs: "312px",
sm: "472px",
md: "584px",
},
position: "absolute",
right: "0px",
top: "0px",
}}
>
<img
src="/images/login-right.png"
alt="flowers"
width="100%"
height="100%"
/>
</Box>
</Box>
);
};
const mockUsers = {
managers: [
{
email: "[email protected]",
firstName: "Michael",
lastName: "Scott",
avatarUrl:
"https://refine-hr-example.s3.eu-west-1.amazonaws.com/avatars/Michael-Scott.png",
},
{
avatarUrl:
"https://refine-hr-example.s3.eu-west-1.amazonaws.com/avatars/Jim-Halpert.png",
firstName: "Jim",
lastName: "Halpert",
email: "[email protected]",
},
{
avatarUrl:
"https://refine-hr-example.s3.eu-west-1.amazonaws.com/avatars/Toby-Flenderson.png",
firstName: "Toby",
lastName: "Flenderson",
email: "[email protected]",
},
],
employees: [
{
avatarUrl:
"https://refine-hr-example.s3.eu-west-1.amazonaws.com/avatars/Pam-Beesly.png",
firstName: "Pam",
lastName: "Beesly",
email: "[email protected]",
},
{
avatarUrl:
"https://refine-hr-example.s3.eu-west-1.amazonaws.com/avatars/Andy-Bernard.png",
firstName: "Andy",
lastName: "Bernard",
email: "[email protected]",
},
{
avatarUrl:
"https://refine-hr-example.s3.eu-west-1.amazonaws.com/avatars/Ryan-Howard.png",
firstName: "Ryan",
lastName: "Howard",
email: "[email protected]",
},
],
};
为了简化身份验证过程,我们创建了一个 mockUsers
对象,其中包含两个数组:managers
和 employees
。每个数组都包含预定义的用户对象。当用户从下拉菜单中选择一个电子邮件并点击 Sign in
按钮时,将调用 login
函数并传入所选电子邮件。login
函数是 Refine 提供的 useLogin
钩子的变更函数。它会使用所选电子邮件调用 authProvider.login
。
接下来,让我们导入 <PageLogin />
组件,并用高亮的更改更新 App.tsx
文件。
import { Authenticated, Refine } from "@refinedev/core";
import { DevtoolsProvider, DevtoolsPanel } from "@refinedev/devtools";
import { ErrorComponent } from "@refinedev/mui";
import dataProvider from "@refinedev/nestjsx-crud";
import routerProvider, {
UnsavedChangesNotifier,
DocumentTitleHandler,
} from "@refinedev/react-router-v6";
import {
BrowserRouter,
Routes,
Route,
Outlet,
Navigate,
} from "react-router-dom";
import { Toaster } from "react-hot-toast";
import { PageLogin } from "@/pages/login";
import { Layout } from "@/components/layout";
import { ThemeProvider } from "@/providers/theme-provider";
import { authProvider } from "@/providers/auth-provider";
import { accessControlProvider } from "@/providers/access-control";
import { useNotificationProvider } from "@/providers/notification-provider";
import { queryClient } from "@/providers/query-client";
import { BASE_URL } from "@/utilities/constants";
import { axiosInstance } from "@/utilities/axios";
import { Role } from './types'
import "@/utilities/init-dayjs";
function App() {
return (
<BrowserRouter>
<ThemeProvider>
<DevtoolsProvider>
<Refine
authProvider={authProvider}
routerProvider={routerProvider}
dataProvider={dataProvider(BASE_URL, axiosInstance)}
notificationProvider={useNotificationProvider}
resources={[
{
name: 'employee',
meta: {
scope: Role.EMPLOYEE,
},
},
{
name: 'manager',
meta: {
scope: Role.MANAGER,
},
},
]}
accessControlProvider={accessControlProvider}
options={{
reactQuery: {
clientConfig: queryClient,
},
syncWithLocation: true,
warnWhenUnsavedChanges: true,
useNewQueryKeys: true,
}}
>
<Routes>
<Route
element={
<Authenticated
key="authenticated-routes"
redirectOnFail="/login"
>
<Layout>
<Outlet />
</Layout>
</Authenticated>
}
>
<Route index element={<h1>Hello World</h1>} />
</Route>
<Route
element={
<Authenticated key="auth-pages" fallback={<Outlet />}>
<Navigate to="/" />
</Authenticated>
}
>
<Route path="/login" element={<PageLogin />} />
</Route>
<Route
element={
<Authenticated key="catch-all">
<Layout>
<Outlet />
</Layout>
</Authenticated>
}
>
<Route path="*" element={<ErrorComponent />} />
</Route>
</Routes>
<UnsavedChangesNotifier />
<DocumentTitleHandler />
<Toaster position="bottom-right" reverseOrder={false} />
<DevtoolsPanel />
</Refine>
</DevtoolsProvider>
</ThemeProvider>
</BrowserRouter>
);
}
export default App;
在更新后的 App.tsx
文件中,我们添加了来自 Refine 的 <Authenticated />
组件。此组件用于保护需要身份验证的路由。它接受 key
属性以唯一标识组件,接受 fallback
属性以在用户未通过身份验证时渲染,以及 redirectOnFail
属性以在身份验证失败时将用户重定向到指定路由。在内部,它调用 authProvider.check
方法来检查用户是否已通过身份验证。
让我们仔细看看 key="auth-pages"
。
<Route
element={
<Authenticated key="auth-pages" fallback={<Outlet />}>
<Navigate to="/" />
</Authenticated>
}
>
<Route path="/login" element={<PageLogin />} />
</Route>
<Authenticated />
组件包装了“/login”路由,以检查用户的身份验证状态。
fallback={<Outlet />}
: 如果用户未经过身份验证,则渲染嵌套路由(即显示<PageLogin />
组件)。- 子组件(
<Navigate to="/" />
): 如果用户已经过身份验证,则重定向他们到主页(/
)。
让我们仔细看一下我们在key="catch-all"
上有什么
<Route
element={
<Authenticated key="catch-all">
<Layout>
<Outlet />
</Layout>
</Authenticated>
}
>
<Route path="*" element={<ErrorComponent />} />
</Route>
<Authenticated />
组件包装了path="*"
路由,以检查用户的身份验证状态。这个路由是一个捕获所有路由,当用户经过身份验证时呈现<ErrorComponent />
。它允许我们在用户尝试访问不存在的路由时显示一个404页面。
现在,当您运行应用并导航到http://localhost:5173/login
,您应该可以看到登录页面,并有下拉菜单选择用户。
现在,“/”页面什么也没有做。在接下来的步骤中,我们将实现Time Off
和Requests
页面。
第4步 — 构建休假页面
构建请假列表页面
在这一步中,我们将构建请假
页面。员工可以申请请假并查看他们的请假历史。经理们也可以查看他们的历史,但他们可以直接将请假分配给自己,而不是申请请假。我们将使用Refine的accessControlProvider
、<CanAccess />
组件和useCan
钩子来实现这一功能。

在开始构建请假页面之前,我们需要创建几个组件来显示请假历史、即将到来的请假请求以及已使用请假的统计信息。在此步骤结束时,我们将使用这些组件来构建请假页面。
构建<TimeOffList />
组件以显示请假历史
在src/components
文件夹中创建一个名为time-offs
的新文件夹。在time-offs
文件夹中,创建一个名为list.tsx
的新文件,并添加以下代码:
import { useState } from "react";
import {
type CrudFilters,
type CrudSort,
useDelete,
useGetIdentity,
useInfiniteList,
} from "@refinedev/core";
import {
Box,
Button,
CircularProgress,
IconButton,
Popover,
Typography,
} from "@mui/material";
import InfiniteScroll from "react-infinite-scroll-component";
import dayjs from "dayjs";
import { DateField } from "@refinedev/mui";
import { Frame } from "@/components/frame";
import { LoadingOverlay } from "@/components/loading-overlay";
import { red } from "@/providers/theme-provider/colors";
import {
AnnualLeaveIcon,
CasualLeaveIcon,
DeleteIcon,
NoTimeOffIcon,
SickLeaveIcon,
ThreeDotsIcon,
PopoverTipIcon,
} from "@/icons";
import { type Employee, TimeOffStatus, type TimeOff } from "@/types";
const variantMap = {
Annual: {
label: "Annual Leave",
iconColor: "primary.700",
iconBgColor: "primary.50",
icon: <AnnualLeaveIcon width={16} height={16} />,
},
Sick: {
label: "Sick Leave",
iconColor: "#C2410C",
iconBgColor: "#FFF7ED",
icon: <SickLeaveIcon width={16} height={16} />,
},
Casual: {
label: "Casual Leave",
iconColor: "grey.700",
iconBgColor: "grey.50",
icon: <CasualLeaveIcon width={16} height={16} />,
},
} as const;
type Props = {
type: "upcoming" | "history" | "inReview";
};
export const TimeOffList = (props: Props) => {
const { data: employee } = useGetIdentity<Employee>();
const { data, isLoading, hasNextPage, fetchNextPage } =
useInfiniteList<TimeOff>({
resource: "time-offs",
sorters: sorters[props.type],
filters: [
...filters[props.type],
{
field: "employeeId",
operator: "eq",
value: employee?.id,
},
],
queryOptions: {
enabled: !!employee?.id,
},
});
const timeOffHistory = data?.pages.flatMap((page) => page.data) || [];
const hasData = isLoading || timeOffHistory.length !== 0;
if (props.type === "inReview" && !hasData) {
return null;
}
return (
<Frame
sx={(theme) => ({
maxHeight: "362px",
paddingBottom: 0,
position: "relative",
"&::after": {
pointerEvents: "none",
content: '""',
position: "absolute",
bottom: 0,
left: "24px",
right: "24px",
width: "80%",
height: "32px",
background:
"linear-gradient(180deg, rgba(255, 255, 255, 0), #FFFFFF)",
},
display: "flex",
flexDirection: "column",
})}
sxChildren={{
paddingRight: 0,
paddingLeft: 0,
flex: 1,
overflow: "hidden",
}}
title={title[props.type]}
>
<LoadingOverlay loading={isLoading} sx={{ height: "100%" }}>
{!hasData ? (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "24px",
height: "180px",
}}
>
<NoTimeOffIcon />
<Typography variant="body2" color="text.secondary">
{props.type === "history"
? "No time off used yet."
: "No upcoming time offs scheduled."}
</Typography>
</Box>
) : (
<Box
id="scrollableDiv-timeOffHistory"
sx={(theme) => ({
maxHeight: "312px",
height: "auto",
[theme.breakpoints.up("lg")]: {
height: "312px",
},
overflow: "auto",
paddingLeft: "12px",
paddingRight: "12px",
})}
>
<InfiniteScroll
dataLength={timeOffHistory.length}
next={() => fetchNextPage()}
hasMore={hasNextPage || false}
endMessage={
!isLoading &&
hasData && (
<Box
sx={{
pt: timeOffHistory.length > 3 ? "40px" : "16px",
}}
/>
)
}
scrollableTarget="scrollableDiv-timeOffHistory"
loader={
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "100px",
}}
>
<CircularProgress size={24} />
</Box>
}
>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "12px",
}}
>
{timeOffHistory.map((timeOff) => {
return (
<ListItem
timeOff={timeOff}
key={timeOff.id}
type={props.type}
/>
);
})}
</Box>
</InfiniteScroll>
</Box>
)}
</LoadingOverlay>
</Frame>
);
};
const ListItem = ({
timeOff,
type,
}: { timeOff: TimeOff; type: Props["type"] }) => {
const { mutateAsync: timeOffCancel } = useDelete<TimeOff>();
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const [hovered, setHovered] = useState(false);
const diffrenceOfDays =
dayjs(timeOff.endsAt).diff(dayjs(timeOff.startsAt), "day") + 1;
const isSameDay = dayjs(timeOff.startsAt).isSame(
dayjs(timeOff.endsAt),
"day",
);
return (
<Box
key={timeOff.id}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
sx={{
display: "flex",
alignItems: "center",
gap: "16px",
height: "64px",
paddingLeft: "12px",
paddingRight: "12px",
borderRadius: "64px",
backgroundColor: hovered ? "grey.50" : "transparent",
transition: "background-color 0.2s",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
color: variantMap[timeOff.timeOffType].iconColor,
backgroundColor: variantMap[timeOff.timeOffType].iconBgColor,
width: "40px",
height: "40px",
borderRadius: "100%",
}}
>
{variantMap[timeOff.timeOffType].icon}
</Box>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "4px",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "4px",
}}
>
{isSameDay ? (
<DateField
value={timeOff.startsAt}
color="text.secondary"
variant="caption"
format="MMMM DD"
/>
) : (
<>
<DateField
value={timeOff.startsAt}
color="text.secondary"
variant="caption"
format="MMMM DD"
/>
<Typography variant="caption" color="text.secondary">
-
</Typography>
<DateField
value={timeOff.endsAt}
color="text.secondary"
variant="caption"
format="MMMM DD"
/>
</>
)}
</Box>
<Typography variant="body2">
<span
style={{
fontWeight: 500,
}}
>
{diffrenceOfDays} {diffrenceOfDays > 1 ? "days" : "day"} of{" "}
</span>
{variantMap[timeOff.timeOffType].label}
</Typography>
</Box>
{hovered && (type === "inReview" || type === "upcoming") && (
<IconButton
onClick={(e) => setAnchorEl(e.currentTarget)}
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "40px",
height: "40px",
marginLeft: "auto",
backgroundColor: "white",
borderRadius: "100%",
color: "grey.400",
border: (theme) => `1px solid ${theme.palette.grey[400]}`,
flexShrink: 0,
}}
>
<ThreeDotsIcon />
</IconButton>
)}
<Popover
id={timeOff.id.toString()}
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={() => {
setAnchorEl(null);
setHovered(false);
}}
anchorOrigin={{
vertical: "bottom",
horizontal: "center",
}}
transformOrigin={{
vertical: "top",
horizontal: "center",
}}
sx={{
"& .MuiPaper-root": {
overflow: "visible",
borderRadius: "12px",
border: (theme) => `1px solid ${theme.palette.grey[400]}`,
boxShadow: "0px 0px 0px 4px rgba(222, 229, 237, 0.25)",
},
}}
>
<Button
variant="text"
onClick={async () => {
await timeOffCancel({
resource: "time-offs",
id: timeOff.id,
invalidates: ["all"],
successNotification: () => {
return {
type: "success",
message: "Time off request cancelled successfully.",
};
},
});
}}
sx={{
position: "relative",
width: "200px",
height: "56px",
paddingLeft: "16px",
color: red[900],
display: "flex",
gap: "12px",
justifyContent: "flex-start",
"&:hover": {
backgroundColor: "transparent",
},
}}
>
<DeleteIcon />
<Typography variant="body2">Cancel Request</Typography>
<Box
sx={{
width: "40px",
height: "16px",
position: "absolute",
top: "-2px",
left: "calc(50% - 1px)",
transform: "translate(-50%, -50%)",
}}
>
<PopoverTipIcon />
</Box>
</Button>
</Popover>
</Box>
);
};
const today = dayjs().toISOString();
const title: Record<Props["type"], string> = {
history: "Time Off History",
upcoming: "Upcoming Time Off",
inReview: "In Review",
};
const filters: Record<Props["type"], CrudFilters> = {
history: [
{
field: "status",
operator: "eq",
value: TimeOffStatus.APPROVED,
},
{
field: "endsAt",
operator: "lt",
value: today,
},
],
upcoming: [
{
field: "status",
operator: "eq",
value: TimeOffStatus.APPROVED,
},
{
field: "endsAt",
operator: "gte",
value: today,
},
],
inReview: [
{
field: "status",
operator: "eq",
value: TimeOffStatus.PENDING,
},
],
};
const sorters: Record<Props["type"], CrudSort[]> = {
history: [{ field: "startsAt", order: "desc" }],
upcoming: [{ field: "endsAt", order: "asc" }],
inReview: [{ field: "startsAt", order: "asc" }],
};
list.tsx
文件内容较长,但大部分内容涉及样式和UI展示。

我们将在三种不同的上下文中使用这个<TimeOffList />
组件:
<TimeOffList type="inReview" />
<TimeOffList type="upcoming" />
<TimeOffList type="history" />
type
属性确定要显示哪种类型的休假列表:
inReview
:显示等待批准的休假请求。upcoming
:显示已经批准但尚未发生的即将到来的休假。history
:列出已经批准并已经发生的休假。
在组件内部,我们将基于type
属性创建过滤器和排序器。我们将使用这些过滤器和排序器从API获取休假数据。
让我们逐步分解组件的关键部分:
1. 获取当前用户
const { data: employee } = useGetIdentity<Employee>();
useGetIdentity<Employee>()
:获取当前用户的信息。- 我们使用员工的ID来过滤休假,以便每个用户只看到自己的请求。
2. 使用无限滚动获取休假数据
const { data, isLoading, hasNextPage, fetchNextPage } =
useInfiniteList <
TimeOff >
{
resource: "time-offs",
sorters: sorters[props.type],
filters: [
...filters[props.type],
{ field: "employeeId", operator: "eq", value: employee?.id },
],
queryOptions: { enabled: !!employee?.id },
};
// ...
<InfiniteScroll
dataLength={timeOffHistory.length}
next={() => fetchNextPage()}
hasMore={hasNextPage || false}
// ...其他属性
>
{/* 在此处渲染列表项 */}
</InfiniteScroll>;
-
useInfiniteList<TimeOff>()
: 获取无限滚动的请假数据。resource
: 指定API端点。sorters
和filters
: 根据type
调整以获取正确的数据。employeeId
过滤器: 确保只获取当前用户的请假记录。queryOptions.enabled
: 仅在员工数据可用时运行查询。
-
<InfiniteScroll />
: 允许用户向下滚动时加载更多数据。next
: 获取下一页数据的功能。hasMore
: 指示是否还有更多数据可用。
3. 取消请假请求
const { mutateAsync: timeOffCancel } = useDelete<TimeOff>();
// 在 ListItem 组件内部
await timeOffCancel({
resource: "time-offs",
id: timeOff.id,
invalidates: ["all"],
successNotification: () => ({
type: "success",
message: "Time off request cancelled successfully.",
}),
});
useDelete
: 提供timeOffCancel
函数用于删除请假请求。- 用于用户取消请假时。
- 完成后显示成功消息。
4. 使用 <DateField />
显示日期
<DateField
value={timeOff.startsAt}
color="text.secondary"
variant="caption"
format="MMMM DD"
/>
<DateField />
: 以用户友好的方式格式化和显示日期。value
: 要显示的日期。format
: 指定日期格式(例如,“一月 05”)。
5. 基于type
创建过滤器和排序器
过滤器:
const filters: Record<Props["type"], CrudFilters> = {
history: [
{
field: "status",
operator: "eq",
value: TimeOffStatus.APPROVED,
},
{
field: "endsAt",
operator: "lt",
value: today,
},
],
// ... 其他类型
};
- 定义根据状态和日期获取请假时间的标准。
history
: 获取已批准且已结束的请假时间。upcoming
: 获取即将到来的已批准请假时间。
排序器:
const sorters: Record<Props["type"], CrudSort[]> = {
history: [{ field: "startsAt", order: "desc" }],
// ... 其他类型
};
- 确定获取数据的顺序。
history
: 按开始日期降序排序。
构建<TimeOffLeaveCards />
组件以显示已使用请假时间的统计信息。
在 src/components/time-offs
文件夹中创建一个名为 leave-cards.tsx
的新文件,并添加以下代码:
import { useGetIdentity, useList } from "@refinedev/core";
import { Box, Grid, Skeleton, Typography } from "@mui/material";
import { AnnualLeaveIcon, CasualLeaveIcon, SickLeaveIcon } from "@/icons";
import {
type Employee,
TimeOffStatus,
TimeOffType,
type TimeOff,
} from "@/types";
type Props = {
employeeId?: number;
};
export const TimeOffLeaveCards = (props: Props) => {
const { data: employee, isLoading: isLoadingEmployee } =
useGetIdentity<Employee>({
queryOptions: {
enabled: !props.employeeId,
},
});
const { data: timeOffsSick, isLoading: isLoadingTimeOffsSick } =
useList<TimeOff>({
resource: "time-offs",
// 我们只需要请病假的总天数,因此可以将 pageSize 设置为 1,以减少负载
pagination: { pageSize: 1 },
filters: [
{
field: "status",
operator: "eq",
value: TimeOffStatus.APPROVED,
},
{
field: "timeOffType",
operator: "eq",
value: TimeOffType.SICK,
},
{
field: "employeeId",
operator: "eq",
value: employee?.id,
},
],
queryOptions: {
enabled: !!employee?.id,
},
});
const { data: timeOffsCasual, isLoading: isLoadingTimeOffsCasual } =
useList<TimeOff>({
resource: "time-offs",
// 我们只需要请病假的总天数,因此可以将 pageSize 设置为 1,以减少负载
pagination: { pageSize: 1 },
filters: [
{
field: "status",
operator: "eq",
value: TimeOffStatus.APPROVED,
},
{
field: "timeOffType",
operator: "eq",
value: TimeOffType.CASUAL,
},
{
field: "employeeId",
operator: "eq",
value: employee?.id,
},
],
queryOptions: {
enabled: !!employee?.id,
},
});
const loading =
isLoadingEmployee || isLoadingTimeOffsSick || isLoadingTimeOffsCasual;
return (
<Grid container spacing="24px">
<Grid item xs={12} sm={4}>
<Card
loading={loading}
type="annual"
value={employee?.availableAnnualLeaveDays || 0}
/>
</Grid>
<Grid item xs={12} sm={4}>
<Card loading={loading} type="sick" value={timeOffsSick?.total || 0} />
</Grid>
<Grid item xs={12} sm={4}>
<Card
loading={loading}
type="casual"
value={timeOffsCasual?.total || 0}
/>
</Grid>
</Grid>
);
};
const variantMap = {
annual: {
label: "Annual Leave",
description: "Days available",
bgColor: "primary.50",
titleColor: "primary.900",
descriptionColor: "primary.700",
iconColor: "primary.700",
icon: <AnnualLeaveIcon />,
},
sick: {
label: "Sick Leave",
description: "Days used",
bgColor: "#FFF7ED",
titleColor: "#7C2D12",
descriptionColor: "#C2410C",
iconColor: "#C2410C",
icon: <SickLeaveIcon />,
},
casual: {
label: "Casual Leave",
description: "Days used",
bgColor: "grey.50",
titleColor: "grey.900",
descriptionColor: "grey.700",
iconColor: "grey.700",
icon: <CasualLeaveIcon />,
},
};
const Card = (props: {
type: "annual" | "sick" | "casual";
value: number;
loading?: boolean;
}) => {
return (
<Box
sx={{
backgroundColor: variantMap[props.type].bgColor,
padding: "24px",
borderRadius: "12px",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Typography
variant="h6"
sx={{
color: variantMap[props.type].titleColor,
fontSize: "16px",
fontWeight: 500,
lineHeight: "24px",
}}
>
{variantMap[props.type].label}
</Typography>
<Box
sx={{
color: variantMap[props.type].iconColor,
}}
>
{variantMap[props.type].icon}
</Box>
</Box>
<Box sx={{ marginTop: "8px", display: "flex", flexDirection: "column" }}>
{props.loading ? (
<Box
sx={{
width: "40%",
height: "32px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Skeleton
variant="rounded"
sx={{
width: "100%",
height: "20px",
}}
/>
</Box>
) : (
<Typography
variant="caption"
sx={{
color: variantMap[props.type].descriptionColor,
fontSize: "24px",
lineHeight: "32px",
fontWeight: 600,
}}
>
{props.value}
</Typography>
)}
<Typography
variant="body1"
sx={{
color: variantMap[props.type].descriptionColor,
fontSize: "12px",
lineHeight: "16px",
}}
>
{variantMap[props.type].description}
</Typography>
</Box>
</Box>
);
};

<TimeOffLeaveCards />
组件显示有关员工请假时间的统计信息。它显示三张卡片,分别用于年假、病假和事假,指示可用或已使用的天数。
让我们分解组件的关键部分:
1. 获取数据
- 员工数据: 使用
useGetIdentity
获取当前员工的信息,例如可用的年假天数。 - 请假统计: 使用
useList
获取员工使用的病假和事假的总天数。它将pageSize
设置为 1,因为我们只需要总数,而不是所有详细信息。
2. 显示卡片
- 该组件渲染三个卡片组件,每种请假类型一个。
- 每张卡片显示:
- 请假类型(例如,年假)。
- 可用或已使用的天数。
- 表示请假类型的图标。
3. 处理加载状态
- 如果数据仍在加载,则显示一个骨架占位符,而不是实际数字。
- 将
loading
属性传递给卡片以管理此状态。
4. 卡片组件
- 接收
type
,value
和loading
作为属性。 - 使用
variantMap
来根据请假类型获取正确的标签、颜色和图标。 - 以适当的样式显示请假信息。
构建 <PageEmployeeTimeOffsList />
现在我们已经有了列出请假时间和显示请假卡片的组件,让我们在 src/pages/employee/time-offs/
文件夹中创建一个名为 list.tsx
的新文件,并添加以下代码:
import { CanAccess, useCan } from "@refinedev/core";
import { CreateButton } from "@refinedev/mui";
import { Box, Grid } from "@mui/material";
import { PageHeader } from "@/components/layout/page-header";
import { TimeOffList } from "@/components/time-offs/list";
import { TimeOffLeaveCards } from "@/components/time-offs/leave-cards";
import { TimeOffIcon } from "@/icons";
import { ThemeProvider } from "@/providers/theme-provider";
import { Role } from "@/types";
export const PageEmployeeTimeOffsList = () => {
const { data: useCanData } = useCan({
action: "manager",
params: {
resource: {
name: "time-offs",
meta: {
scope: "manager",
},
},
},
});
const isManager = useCanData?.can;
return (
<ThemeProvider role={isManager ? Role.MANAGER : Role.EMPLOYEE}>
<Box>
<PageHeader
title="Time Off"
rightSlot={
<CreateButton
size="large"
variant="contained"
startIcon={<TimeOffIcon />}
>
<CanAccess action="manager" fallback="Request Time Off">
Assign Time Off
</CanAccess>
</CreateButton>
}
/>
<TimeOffLeaveCards />
<Grid
container
spacing="24px"
sx={{
marginTop: "24px",
}}
>
<Grid item xs={12} md={6}>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "24px",
}}
>
<TimeOffList type="inReview" />
<TimeOffList type="upcoming" />
</Box>
</Grid>
<Grid item xs={12} md={6}>
<TimeOffList type="history" />
</Grid>
</Grid>
</Box>
</ThemeProvider>
);
};
<PageEmployeeTimeOffsList />
是请假页面的主要组件,我们将使用此组件在用户导航到 /employee/time-offs
路由时显示请假列表和请假卡片。

让我们分解组件的关键部分:
1. 检查用户角色
- 使用
useCan
钩子来判断当前用户是否为经理。 - 如果用户具有经理权限,则将
isManager
设置为true
。
2. 根据角色应用主题
- 将内容包裹在
<ThemeProvider />
中。 - 主题根据用户是经理还是员工而变化。
3. 带有条件按钮的页面头部
- 渲染一个
<PageHeader />
,标题为“请假”。 - 包含一个
<CreateButton />
,根据用户的角色而变化:- 如果用户是经理,按钮显示“分配请假”。
- 如果用户不是经理,按钮显示“请求请假”。
- 这通过
<CanAccess />
组件处理,该组件检查权限。
4. 显示请假统计信息
- 包含
<TimeOffLeaveCards />
组件,显示请假余额和使用情况。 - 这提供了年度、病假和事假的总结。
5. 列出请假请求
- 使用
<Grid />
布局来组织内容。 - 在左侧(
md={6}
),显示:TimeOffList
,type="inReview"
:显示待处理的请假请求。TimeOffList
,type="upcoming"
:显示即将批准的请假。
- 右侧(
md={6}
)显示:TimeOffList
,类型为type="history"
:显示已经发生的过去请假。
添加“/employee/time-offs”路由
我们准备在 /employee/time-offs
路由上渲染 <PageEmployeeTimeOffsList />
组件。让我们更新 App.tsx
文件以包含此路由:
import { Authenticated, ErrorComponent, Refine } from '@refinedev/core'
import { DevtoolsProvider, DevtoolsPanel } from '@refinedev/devtools'
import dataProvider from '@refinedev/nestjsx-crud'
import routerProvider, {
UnsavedChangesNotifier,
DocumentTitleHandler,
NavigateToResource,
} from '@refinedev/react-router-v6'
import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import { PageEmployeeTimeOffsList } from '@/pages/employee/time-offs/list'
import { PageLogin } from '@/pages/login'
import { Layout } from '@/components/layout'
import { ThemeProvider } from '@/providers/theme-provider'
import { authProvider } from '@/providers/auth-provider'
import { accessControlProvider } from '@/providers/access-control'
import { useNotificationProvider } from '@/providers/notification-provider'
import { queryClient } from '@/providers/query-client'
import { BASE_URL } from '@/utilities/constants'
import { axiosInstance } from '@/utilities/axios'
import { TimeOffIcon } from '@/icons'
import { Role } from '@/types'
import '@/utilities/init-dayjs'
function App() {
return (
<BrowserRouter>
<ThemeProvider>
<DevtoolsProvider>
<Refine
authProvider={authProvider}
routerProvider={routerProvider}
dataProvider={dataProvider(BASE_URL, axiosInstance)}
notificationProvider={useNotificationProvider}
resources={[
{
name: 'employee',
meta: {
scope: Role.EMPLOYEE,
},
},
{
name: 'manager',
meta: {
scope: Role.MANAGER,
},
},
{
name: 'time-offs',
list: '/employee/time-offs',
meta: {
parent: 'employee',
scope: Role.EMPLOYEE,
label: 'Time Off',
icon: <TimeOffIcon />,
},
},
]}
accessControlProvider={accessControlProvider}
options={{
reactQuery: {
clientConfig: queryClient,
},
syncWithLocation: true,
warnWhenUnsavedChanges: true,
useNewQueryKeys: true,
}}>
<Routes>
<Route
element={
<Authenticated key='authenticated-routes' redirectOnFail='/login'>
<Outlet />
</Authenticated>
}>
<Route index element={<NavigateToResource resource='time-offs' />} />
<Route
path='employee'
element={
<ThemeProvider role={Role.EMPLOYEE}>
<Layout>
<Outlet />
</Layout>
</ThemeProvider>
}>
<Route path='time-offs' element={<Outlet />}>
<Route index element={<PageEmployeeTimeOffsList />} />
</Route>
</Route>
</Route>
<Route
element={
<Authenticated key='auth-pages' fallback={<Outlet />}>
<NavigateToResource resource='time-offs' />
</Authenticated>
}>
<Route path='/login' element={<PageLogin />} />
</Route>
<Route
element={
<Authenticated key="catch-all">
<Layout>
<Outlet />
</Layout>
</Authenticated>
}
>
<Route path="*" element={<ErrorComponent />} />
</Route>
</Routes>
<UnsavedChangesNotifier />
<DocumentTitleHandler />
<Toaster position='bottom-right' reverseOrder={false} />
<DevtoolsPanel />
</Refine>
</DevtoolsProvider>
</ThemeProvider>
</BrowserRouter>
)
}
export default App
让我们分解更新后的 App.tsx
文件的关键部分:
1. 定义请假资源
{
name: 'time-offs',
list: '/employee/time-offs',
meta: {
parent: 'employee',
scope: Role.EMPLOYEE,
label: 'Time Off',
icon: <TimeOffIcon />,
},
}
我们为请假添加了一个新的 资源,作为 employee
资源 的子项。这表明请假与员工相关,并且可以由员工访问。
name: 'time-offs'
:这是该资源的标识符,由 Refine 内部使用。list: '/employee/time-offs'
:指定显示资源列表视图的路由。元数据
: 一个包含有关资源的附加元数据的对象。父级: '员工'
: 将此资源归类于员工
范围,可用于在用户界面中组织资源(例如在侧边菜单中)或用于访问控制。范围: Role.EMPLOYEE
: 表示该资源对具有EMPLOYEE
角色的用户可访问。我们在accessControlProvider
中使用此信息来管理权限。标签: '请假'
: 该资源在用户界面中的显示名称。图标: <TimeOffIcon />
: 将TimeOffIcon
与此资源关联以便于视觉识别。
2. 当用户导航到/
路由时重定向到“请假”资源。
<Route index element={<NavigateToResource resource="time-offs" />} />
我们使用 <NavigateToResource />
组件来在用户导航到 /
路由时重定向他们到 time-offs
资源。这确保用户默认看到请假列表。
3. 当用户已认证时重定向到“time-offs”资源
<Route
element={
<Authenticated key="auth-pages" fallback={<Outlet />}>
<NavigateToResource resource="time-offs" />
</Authenticated>
}
>
<Route path="/login" element={<PageLogin />} />
</Route>
当用户已认证时,我们将他们重定向到 time-offs
资源。如果他们未认证,他们会看到登录页面。
4. 添加 /employee/time-offs
路由
<Route
path="employee"
element={
<ThemeProvider role={Role.EMPLOYEE}>
<Layout>
<Outlet />
</Layout>
</ThemeProvider>
}
>
<Route path="time-offs" element={<Outlet />}>
<Route index element={<PageEmployeeTimeOffsList />} />
</Route>
</Route>
我们使用嵌套路由组织员工页面。首先,我们创建一个主路由 path='employee'
,将内容包装在员工特定的主题和布局中。在此路由内部,我们添加 path='time-offs'
,它显示 PageEmployeeTimeOffsList
组件。这种结构将所有员工功能归类在一个路径下,并保持样式一致。
在添加这些更改后,您可以导航到 /employee/time-offs
路由以查看请假列表页面的运行情况。

现在,请假列表页面是功能性的,但缺少创建新请假请求的能力。让我们添加创建新请假请求的能力。
构建请假申请创建页面
我们将创建一个新页面来请求或分配休假时间。该页面将包括一个表单,用户可以在其中指定休假类型、开始和结束日期以及任何附加说明。
在开始之前,我们需要创建新组件来在表单中使用:
构建 <TimeOffFormSummary />
组件
在 src/components/time-offs/
文件夹中创建一个名为 form-summary.tsx
的新文件,并添加以下代码:
import { Box, Divider, Typography } from "@mui/material";
type Props = {
availableAnnualDays: number;
requestedDays: number;
};
export const TimeOffFormSummary = (props: Props) => {
const remainingDays = props.availableAnnualDays - props.requestedDays;
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
gap: "16px",
whiteSpace: "nowrap",
}}
>
<Box
sx={{
display: "flex",
gap: "16px",
}}
>
<Typography variant="body2" color="text.secondary">
Available Annual Leave Days:
</Typography>
<Typography variant="body2">{props.availableAnnualDays}</Typography>
</Box>
<Box
sx={{
display: "flex",
gap: "16px",
}}
>
<Typography variant="body2" color="text.secondary">
Requested Days:
</Typography>
<Typography variant="body2">{props.requestedDays}</Typography>
</Box>
<Divider
sx={{
width: "100%",
}}
/>
<Box
sx={{
display: "flex",
gap: "16px",
height: "40px",
}}
>
<Typography variant="body2" color="text.secondary">
Remaining Days:
</Typography>
<Typography variant="body2" fontWeight={500}>
{remainingDays}
</Typography>
</Box>
</Box>
);
};

<TimeOffFormSummary />
组件显示休假请求的摘要。它显示可用的年假天数、请求的天数以及剩余天数。我们将在休假表单中使用此组件,为用户提供其请求的清晰概览。
构建 <PageEmployeeTimeOffsCreate />
组件
在 src/pages/employee/time-offs/
文件夹中创建一个名为 create.tsx
的新文件,并添加以下代码:
import { useCan, useGetIdentity, type HttpError } from "@refinedev/core";
import { useForm } from "@refinedev/react-hook-form";
import { Controller } from "react-hook-form";
import type { DateRange } from "@mui/x-date-pickers-pro/models";
import { Box, Button, MenuItem, Select, Typography } from "@mui/material";
import dayjs from "dayjs";
import { PageHeader } from "@/components/layout/page-header";
import { InputText } from "@/components/input/text";
import { LoadingOverlay } from "@/components/loading-overlay";
import { InputDateStartsEnds } from "@/components/input/date-starts-ends";
import { TimeOffFormSummary } from "@/components/time-offs/form-summary";
import { ThemeProvider } from "@/providers/theme-provider";
import {
type Employee,
type TimeOff,
TimeOffType,
TimeOffStatus,
Role,
} from "@/types";
import { CheckRectangleIcon } from "@/icons";
type FormValues = Omit<TimeOff, "id" | "notes"> & {
notes: string;
dates: DateRange<dayjs.Dayjs>;
};
export const PageEmployeeTimeOffsCreate = () => {
const { data: useCanData } = useCan({
action: "manager",
params: {
resource: {
name: "time-offs",
meta: {
scope: "manager",
},
},
},
});
const isManager = useCanData?.can;
const { data: employee } =
useGetIdentity<Employee>();
const {
refineCore: { formLoading, onFinish },
...formMethods
} = useForm<TimeOff, HttpError, FormValues>({
defaultValues: {
timeOffType: TimeOffType.ANNUAL,
notes: "",
dates: [null, null],
},
refineCoreProps: {
successNotification: () => {
return {
message: isManager
? "Time off assigned"
: "Your time off request is submitted for review.",
type: "success",
};
},
},
});
const { control, handleSubmit, formState, watch } = formMethods;
const onFinishHandler = async (values: FormValues) => {
const payload: FormValues = {
...values,
startsAt: dayjs(values.dates[0]).format("YYYY-MM-DD"),
endsAt: dayjs(values.dates[1]).format("YYYY-MM-DD"),
...(isManager && {
status: TimeOffStatus.APPROVED,
}),
};
await onFinish(payload);
};
const timeOffType = watch("timeOffType");
const selectedDays = watch("dates");
const startsAt = selectedDays[0];
const endsAt = selectedDays[1];
const availableAnnualDays = employee?.availableAnnualLeaveDays ?? 0;
const requestedDays =
startsAt && endsAt ? endsAt.diff(startsAt, "day") + 1 : 0;
return (
<ThemeProvider role={isManager ? Role.MANAGER : Role.EMPLOYEE}>
<LoadingOverlay loading={formLoading}>
<Box>
<PageHeader
title={isManager ? "Assign Time Off" : "Request Time Off"}
showListButton
showDivider
/>
<Box
component="form"
onSubmit={handleSubmit(onFinishHandler)}
sx={{
display: "flex",
flexDirection: "column",
gap: "24px",
marginTop: "24px",
}}
>
<Box>
<Typography
variant="body2"
sx={{
mb: "8px",
}}
>
Time Off Type
</Typography>
<Controller
name="timeOffType"
control={control}
render={({ field }) => (
<Select
{...field}
size="small"
sx={{
minWidth: "240px",
height: "40px",
"& .MuiSelect-select": {
paddingBlock: "10px",
},
}}
>
<MenuItem value={TimeOffType.ANNUAL}>Annual Leave</MenuItem>
<MenuItem value={TimeOffType.CASUAL}>Casual Leave</MenuItem>
<MenuItem value={TimeOffType.SICK}>Sick Leave</MenuItem>
</Select>
)}
/>
</Box>
<Box>
<Typography
variant="body2"
sx={{
mb: "16px",
}}
>
Requested Dates
</Typography>
<Controller
name="dates"
control={control}
rules={{
validate: (value) => {
if (!value[0] || !value[1]) {
return "Please select both start and end dates";
}
return true;
},
}}
render={({ field }) => {
return (
<Box
sx={{
display: "grid",
gridTemplateColumns: () => {
return {
sm: "1fr",
lg: "628px 1fr",
};
},
gap: "40px",
}}
>
<InputDateStartsEnds
{...field}
error={formState.errors.dates?.message}
availableAnnualDays={availableAnnualDays}
requestedDays={requestedDays}
/>
{timeOffType === TimeOffType.ANNUAL && (
<Box
sx={{
display: "flex",
maxWidth: "628px",
alignItems: () => {
return {
lg: "flex-end",
};
},
justifyContent: () => {
return {
xs: "flex-end",
lg: "flex-start",
};
},
}}
>
<TimeOffFormSummary
availableAnnualDays={availableAnnualDays}
requestedDays={requestedDays}
/>
</Box>
)}
</Box>
);
}}
/>
</Box>
<Box
sx={{
maxWidth: "628px",
}}
>
<Controller
name="notes"
control={control}
render={({ field, fieldState }) => {
return (
<InputText
{...field}
label="Notes"
error={fieldState.error?.message}
placeholder="Place enter your notes"
multiline
rows={3}
/>
);
}}
/>
</Box>
<Button
variant="contained"
size="large"
type="submit"
startIcon={isManager ? <CheckRectangleIcon /> : undefined}
>
{isManager ? "Assign" : "Send Request"}
</Button>
</Box>
</Box>
</LoadingOverlay>
</ThemeProvider>
);
};

<PageEmployeeTimeOffsCreate />
组件在 HR 管理应用中显示一个用于创建新休假请求的表单。员工和经理都可以使用它来请求或分配休假。该表单包括选择休假类型、选择开始和结束日期、添加备注的选项,并显示请求的休假摘要。
让我们分解组件的关键部分:
1. 检查用户角色
const { data: useCanData } = useCan({
action: "manager",
params: {
resource: {
name: "time-offs",
meta: {
scope: "manager",
},
},
},
});
const isManager = useCanData?.can;
使用useCan
钩子,我们检查当前用户是否具有经理权限。这决定了用户是可以分配休假还是只能请求休假。我们将在onFinishHandler
中根据用户的角色以不同的方式处理表单提交。
2. 表单状态和提交
const {
refineCore: { formLoading, onFinish },
...formMethods
} = useForm<TimeOff, HttpError, FormValues>({
defaultValues: {
timeOffType: TimeOffType.ANNUAL,
notes: "",
dates: [null, null],
},
refineCoreProps: {
successNotification: () => {
return {
message: isManager
? "Time off assigned"
: "Your time off request is submitted for review.",
type: "success",
};
},
},
});
const { control, handleSubmit, formState, watch } = formMethods;
const onFinishHandler = async (values: FormValues) => {
const payload: FormValues = {
...values,
startsAt: dayjs(values.dates[0]).format("YYYY-MM-DD"),
endsAt: dayjs(values.dates[1]).format("YYYY-MM-DD"),
...(isManager && {
status: TimeOffStatus.APPROVED,
}),
};
await onFinish(payload);
};
useForm
使用默认值初始化表单,并根据用户的角色设置成功通知。onFinishHandler
函数在提交之前处理表单数据。对于经理,状态会立即设置为APPROVED
,而员工的请求将提交审核。
3. 样式
<ThemeProvider role={isManager ? Role.MANAGER : Role.EMPLOYEE}>
{/* ... */}
</ThemeProvider>
<Button
variant="contained"
size="large"
type="submit"
startIcon={isManager ? <CheckRectangleIcon /> : undefined}
>
{isManager ? "Assign" : "Send Request"}
</Button>
在我们的设计中,主色调会根据用户的角色变化。我们使用<ThemeProvider />
相应地应用正确的主题。提交按钮的文本和图标也会根据用户是经理还是员工而变化。
4. 添加“/employee/time-offs/create”路由
我们需要为创建休假页面添加新路由。让我们更新App.tsx
文件以包含此路由:
import { Authenticated, ErrorComponent, Refine } from '@refinedev/core'
import { DevtoolsProvider, DevtoolsPanel } from '@refinedev/devtools'
import dataProvider from '@refinedev/nestjsx-crud'
import routerProvider, {
UnsavedChangesNotifier,
DocumentTitleHandler,
NavigateToResource,
} from '@refinedev/react-router-v6'
import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import { PageEmployeeTimeOffsList } from '@/pages/employee/time-offs/list'
import { PageEmployeeTimeOffsCreate } from '@/pages/employee/time-offs/create'
import { PageLogin } from '@/pages/login'
import { Layout } from '@/components/layout'
import { ThemeProvider } from '@/providers/theme-provider'
import { authProvider } from '@/providers/auth-provider'
import { accessControlProvider } from '@/providers/access-control'
import { useNotificationProvider } from '@/providers/notification-provider'
import { queryClient } from '@/providers/query-client'
import { BASE_URL } from '@/utilities/constants'
import { axiosInstance } from '@/utilities/axios'
import { TimeOffIcon } from '@/icons'
import { Role } from '@/types'
import '@/utilities/init-dayjs'
function App() {
return (
<BrowserRouter>
<ThemeProvider>
<DevtoolsProvider>
<Refine
authProvider={authProvider}
routerProvider={routerProvider}
dataProvider={dataProvider(BASE_URL, axiosInstance)}
notificationProvider={useNotificationProvider}
resources={[
{
name: 'employee',
meta: {
scope: Role.EMPLOYEE,
order: 2,
},
},
{
name: 'manager',
meta: {
scope: Role.MANAGER,
order: 1,
},
},
{
name: 'time-offs',
list: '/employee/time-offs',
create: '/employee/time-offs/new',
meta: {
parent: 'employee',
scope: Role.EMPLOYEE,
label: 'Time Off',
icon: <TimeOffIcon />,
},
},
]}
accessControlProvider={accessControlProvider}
options={{
reactQuery: {
clientConfig: queryClient,
},
syncWithLocation: true,
warnWhenUnsavedChanges: true,
useNewQueryKeys: true,
}}>
<Routes>
<Route
element={
<Authenticated key='authenticated-routes' redirectOnFail='/login'>
<Outlet />
</Authenticated>
}>
<Route index element={<NavigateToResource resource='time-offs' />} />
<Route
path='employee'
element={
<ThemeProvider role={Role.EMPLOYEE}>
<Layout>
<Outlet />
</Layout>
</ThemeProvider>
}>
<Route path='time-offs' element={<Outlet />}>
<Route index element={<PageEmployeeTimeOffsList />} />
<Route path='new' element={<PageEmployeeTimeOffsCreate />} />
</Route>
</Route>
</Route>
<Route
element={
<Authenticated key='auth-pages' fallback={<Outlet />}>
<NavigateToResource resource='time-offs' />
</Authenticated>
}>
<Route path='/login' element={<PageLogin />} />
</Route>
<Route
element={
<Authenticated key='catch-all'>
<Layout>
<Outlet />
</Layout>
</Authenticated>
}>
<Route path='*' element={<ErrorComponent />} />
</Route>
</Routes>
<UnsavedChangesNotifier />
<DocumentTitleHandler />
<Toaster position='bottom-right' reverseOrder={false} />
<DevtoolsPanel />
</Refine>
</DevtoolsProvider>
</ThemeProvider>
</BrowserRouter>
)
}
export default App
添加这些更改后,您可以导航到/employee/time-offs/create
路由,或点击休假列表页面上的“分配休假”按钮以访问创建休假表单。

步骤 5 — 创建请假请求管理页面
在此步骤中,我们将创建一个新的页面来管理请假请求。该页面将允许管理者审核并批准或拒绝员工提交的请假请求。

创建请假请求列表页面
我们将创建一个新的页面来管理请假请求。该页面将包含请假请求的列表,显示员工姓名、请假类型、请求日期和当前状态等详细信息。
在开始之前,我们需要创建新的组件以在列表中使用:
创建 <RequestsList />
组件
在 src/components/requests/
文件夹中创建一个名为 list.tsx
的新文件,并添加以下代码:
import type { ReactNode } from "react";
import InfiniteScroll from "react-infinite-scroll-component";
import {
Box,
Button,
CircularProgress,
Skeleton,
Typography,
} from "@mui/material";
type Props = {
dataLength: number;
hasMore: boolean;
scrollableTarget: string;
loading: boolean;
noDataText: string;
noDataIcon: ReactNode;
children: ReactNode;
next: () => void;
};
export const RequestsList = (props: Props) => {
const hasData = props.dataLength > 0 || props.loading;
if (!hasData) {
return (
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
flexDirection: "column",
gap: "24px",
}}
>
{props.noDataIcon}
<Typography variant="body2" color="text.secondary">
{props.noDataText || "No data."}
</Typography>
</Box>
);
}
return (
<Box
sx={{
position: "relative",
}}
>
<Box
id={props.scrollableTarget}
sx={(theme) => ({
maxHeight: "600px",
[theme.breakpoints.up("lg")]: {
height: "600px",
},
overflow: "auto",
...((props.dataLength > 6 || props.loading) && {
"&::after": {
pointerEvents: "none",
content: '""',
zIndex: 1,
position: "absolute",
bottom: "0",
left: "12px",
right: "12px",
width: "calc(100% - 24px)",
height: "60px",
background:
"linear-gradient(180deg, rgba(255, 255, 255, 0), #FFFFFF)",
},
}),
})}
>
<InfiniteScroll
dataLength={props.dataLength}
hasMore={props.hasMore}
next={props.next}
scrollableTarget={props.scrollableTarget}
endMessage={
!props.loading &&
props.dataLength > 6 && (
<Box
sx={{
pt: "40px",
}}
/>
)
}
loader={
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "100px",
}}
>
<CircularProgress size={24} />
</Box>
}
>
<Box
sx={{
display: "flex",
flexDirection: "column",
}}
>
{props.loading ? <SkeletonList /> : props.children}
</Box>
</InfiniteScroll>
</Box>
</Box>
);
};
const SkeletonList = () => {
return (
<>
{[...Array(6)].map((_, index) => (
<Box
key={index}
sx={(theme) => ({
paddingRight: "24px",
paddingLeft: "24px",
display: "flex",
flexDirection: "column",
justifyContent: "flex-end",
gap: "12px",
paddingTop: "12px",
paddingBottom: "4px",
[theme.breakpoints.up("sm")]: {
paddingTop: "20px",
paddingBottom: "12px",
},
"& .MuiSkeleton-rectangular": {
borderRadius: "2px",
},
})}
>
<Skeleton variant="rectangular" width="64px" height="12px" />
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "24px",
}}
>
<Skeleton
variant="circular"
width={48}
height={48}
sx={{
flexShrink: 0,
}}
/>
<Box
sx={(theme) => ({
height: "auto",
width: "100%",
[theme.breakpoints.up("md")]: {
height: "48px",
},
display: "flex",
flex: 1,
flexDirection: "column",
justifyContent: "center",
gap: "8px",
})}
>
<Skeleton
variant="rectangular"
sx={(theme) => ({
width: "100%",
[theme.breakpoints.up("sm")]: {
width: "120px",
},
})}
height="16px"
/>
<Skeleton
variant="rectangular"
sx={(theme) => ({
width: "100%",
[theme.breakpoints.up("sm")]: {
width: "230px",
},
})}
height="12px"
/>
</Box>
<Button
size="small"
color="inherit"
sx={(theme) => ({
display: "none",
[theme.breakpoints.up("sm")]: {
display: "block",
},
alignSelf: "flex-start",
flexShrink: 0,
marginLeft: "auto",
})}
>
View Request
</Button>
</Box>
</Box>
))}
</>
);
};
<RequestsList />
组件显示可无限滚动的请假请求列表。它包括加载指示器、骨架占位符,并在没有数据时显示消息。该组件旨在高效处理大型数据集,并提供流畅的用户体验。
构建 <RequestsListItem />
组件
在 src/components/requests/
文件夹中创建一个名为 list-item.tsx
的新文件,并添加以下代码:
import { Box, Typography, Avatar, Button } from "@mui/material";
import type { ReactNode } from "react";
type Props = {
date: string;
avatarURL: string;
title: string;
descriptionIcon?: ReactNode;
description: string;
onClick?: () => void;
showTimeSince?: boolean;
};
export const RequestsListItem = ({
date,
avatarURL,
title,
descriptionIcon,
description,
onClick,
showTimeSince,
}: Props) => {
return (
<Box
role="button"
onClick={onClick}
sx={(theme) => ({
cursor: "pointer",
paddingRight: "24px",
paddingLeft: "24px",
paddingTop: "4px",
paddingBottom: "4px",
[theme.breakpoints.up("sm")]: {
paddingTop: "12px",
paddingBottom: "12px",
},
"&:hover": {
backgroundColor: theme.palette.action.hover,
},
})}
>
{showTimeSince && (
<Box
sx={{
marginBottom: "8px",
}}
>
<Typography variant="caption" color="textSecondary">
{date}
</Typography>
</Box>
)}
<Box
sx={{
display: "flex",
}}
>
<Avatar
src={avatarURL}
alt={title}
sx={{ width: "48px", height: "48px" }}
/>
<Box
sx={(theme) => ({
height: "auto",
[theme.breakpoints.up("md")]: {
height: "48px",
},
width: "100%",
display: "flex",
flexWrap: "wrap",
justifyContent: "space-between",
gap: "4px",
marginLeft: "16px",
})}
>
<Box>
<Typography variant="body2" fontWeight={500} lineHeight="24px">
{title}
</Typography>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "8px",
minWidth: "260px",
}}
>
{descriptionIcon}
<Typography variant="caption" color="textSecondary">
{description}
</Typography>
</Box>
</Box>
{onClick && (
<Button
size="small"
color="inherit"
onClick={onClick}
sx={{
alignSelf: "flex-start",
flexShrink: 0,
marginLeft: "auto",
}}
>
View Request
</Button>
)}
</Box>
</Box>
</Box>
);
};
<RequestsListItem />
组件在列表中显示单个请假请求。它包括员工的头像、姓名、描述和一个查看请求详情的按钮。该组件是可重用的,可用于渲染请假请求列表中的每个项目。
构建 <PageManagerRequestsList />
组件
在 src/pages/manager/requests/
文件夹中创建一个名为 list.tsx
的新文件,并添加以下代码:
import type { PropsWithChildren } from "react";
import { useGo, useInfiniteList } from "@refinedev/core";
import { Box, Typography } from "@mui/material";
import dayjs from "dayjs";
import { Frame } from "@/components/frame";
import { PageHeader } from "@/components/layout/page-header";
import { RequestsListItem } from "@/components/requests/list-item";
import { RequestsList } from "@/components/requests/list";
import { indigo } from "@/providers/theme-provider/colors";
import { TimeOffIcon, RequestTypeIcon, NoTimeOffIcon } from "@/icons";
import { TimeOffStatus, type Employee, type TimeOff } from "@/types";
export const PageManagerRequestsList = ({ children }: PropsWithChildren) => {
return (
<>
<Box>
<PageHeader title="Awaiting Requests" />
<TimeOffsList />
</Box>
{children}
</>
);
};
const TimeOffsList = () => {
const go = useGo();
const {
data: timeOffsData,
isLoading: timeOffsLoading,
fetchNextPage: timeOffsFetchNextPage,
hasNextPage: timeOffsHasNextPage,
} = useInfiniteList<
TimeOff & {
employee: Employee;
}
>({
resource: "time-offs",
filters: [
{ field: "status", operator: "eq", value: TimeOffStatus.PENDING },
],
sorters: [{ field: "createdAt", order: "desc" }],
meta: {
join: ["employee"],
},
});
const timeOffs = timeOffsData?.pages.flatMap((page) => page.data) || [];
const totalCount = timeOffsData?.pages[0].total;
return (
<Frame
title="Time off Requests"
titleSuffix={
!!totalCount &&
totalCount > 0 && (
<Box
sx={{
padding: "4px",
display: "flex",
alignItems: "center",
justifyContent: "center",
minWidth: "24px",
height: "24px",
borderRadius: "4px",
backgroundColor: indigo[100],
}}
>
<Typography
variant="caption"
sx={{
color: indigo[500],
fontSize: "12px",
lineHeight: "16px",
}}
>
{totalCount}
</Typography>
</Box>
)
}
icon={<TimeOffIcon width={24} height={24} />}
sx={{
flex: 1,
paddingBottom: "0px",
}}
sxChildren={{
padding: 0,
}}
>
<RequestsList
loading={timeOffsLoading}
dataLength={timeOffs.length}
hasMore={timeOffsHasNextPage || false}
next={timeOffsFetchNextPage}
scrollableTarget="scrollableDiv-timeOffs"
noDataText="No time off requests right now."
noDataIcon={<NoTimeOffIcon />}
>
{timeOffs.map((timeOff) => {
const date = dayjs(timeOff.createdAt).fromNow();
const fullName = `${timeOff.employee.firstName} ${timeOff.employee.lastName}`;
const avatarURL = timeOff.employee.avatarUrl;
const requestedDay =
dayjs(timeOff.endsAt).diff(dayjs(timeOff.startsAt), "day") + 1;
const description = `Requested ${requestedDay} ${
requestedDay > 1 ? "days" : "day"
} of time ${timeOff.timeOffType.toLowerCase()} leave.`;
return (
<RequestsListItem
key={timeOff.id}
date={date}
avatarURL={avatarURL}
title={fullName}
showTimeSince
descriptionIcon={<RequestTypeIcon type={timeOff.timeOffType} />}
description={description}
onClick={() => {
go({
type: "replace",
to: {
resource: "requests",
id: timeOff.id,
action: "edit",
},
});
}}
/>
);
})}
</RequestsList>
</Frame>
);
};
<PageManagerRequestsList />
组件显示经理需要批准的待处理请假请求。它显示诸如员工姓名、请假类型、请求日期以及请求提交时间等详细信息。经理可以点击请求以查看更多详细信息。它使用 <RequestsList />
和 <RequestsListItem />
来渲染列表。
该组件还接受 children
作为属性。接下来,我们将实现一个 使用 <Outlet />
的模态路由 来显示请求详情,在组件内部渲染 /manager/requests/:id
路由。
添加 “/manager/requests” 路由
我们需要为请假请求管理页面添加新路由。让我们更新App.tsx
文件以包含此路由:
import { Authenticated, ErrorComponent, Refine } from '@refinedev/core'
import { DevtoolsProvider, DevtoolsPanel } from '@refinedev/devtools'
import dataProvider from '@refinedev/nestjsx-crud'
import routerProvider, {
UnsavedChangesNotifier,
DocumentTitleHandler,
NavigateToResource,
} from '@refinedev/react-router-v6'
import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import { PageEmployeeTimeOffsList } from '@/pages/employee/time-offs/list'
import { PageEmployeeTimeOffsCreate } from '@/pages/employee/time-offs/create'
import { PageLogin } from '@/pages/login'
import { Layout } from '@/components/layout'
import { ThemeProvider } from '@/providers/theme-provider'
import { authProvider } from '@/providers/auth-provider'
import { accessControlProvider } from '@/providers/access-control'
import { useNotificationProvider } from '@/providers/notification-provider'
import { queryClient } from '@/providers/query-client'
import { BASE_URL } from '@/utilities/constants'
import { axiosInstance } from '@/utilities/axios'
import { RequestsIcon, TimeOffIcon } from '@/icons'
import { Role } from '@/types'
import '@/utilities/init-dayjs'
import { PageManagerRequestsList } from './pages/manager/requests/list'
function App() {
return (
<BrowserRouter>
<ThemeProvider>
<DevtoolsProvider>
<Refine
authProvider={authProvider}
routerProvider={routerProvider}
dataProvider={dataProvider(BASE_URL, axiosInstance)}
notificationProvider={useNotificationProvider}
resources={[
{
name: 'employee',
meta: {
scope: Role.EMPLOYEE,
order: 2,
},
},
{
name: 'manager',
meta: {
scope: Role.MANAGER,
order: 1,
},
},
{
name: 'time-offs',
list: '/employee/time-offs',
create: '/employee/time-offs/new',
meta: {
parent: 'employee',
scope: Role.EMPLOYEE,
label: 'Time Off',
icon: <TimeOffIcon />,
},
},
{
name: 'time-offs',
list: '/manager/requests',
identifier: 'requests',
meta: {
parent: 'manager',
scope: Role.MANAGER,
label: 'Requests',
icon: <RequestsIcon />,
},
},
]}
accessControlProvider={accessControlProvider}
options={{
reactQuery: {
clientConfig: queryClient,
},
syncWithLocation: true,
warnWhenUnsavedChanges: true,
useNewQueryKeys: true,
}}>
<Routes>
<Route
element={
<Authenticated key='authenticated-routes' redirectOnFail='/login'>
<Outlet />
</Authenticated>
}>
<Route index element={<NavigateToResource resource='time-offs' />} />
<Route
path='employee'
element={
<ThemeProvider role={Role.EMPLOYEE}>
<Layout>
<Outlet />
</Layout>
</ThemeProvider>
}>
<Route path='time-offs' element={<Outlet />}>
<Route index element={<PageEmployeeTimeOffsList />} />
<Route path='new' element={<PageEmployeeTimeOffsCreate />} />
</Route>
</Route>
</Route>
<Route
path='manager'
element={
<ThemeProvider role={Role.MANAGER}>
<Layout>
<Outlet />
</Layout>
</ThemeProvider>
}>
<Route path='requests' element={<Outlet />}>
<Route index element={<PageManagerRequestsList />} />
</Route>
</Route>
<Route
element={
<Authenticated key='auth-pages' fallback={<Outlet />}>
<NavigateToResource resource='time-offs' />
</Authenticated>
}>
<Route path='/login' element={<PageLogin />} />
</Route>
<Route
element={
<Authenticated key='catch-all'>
<Layout>
<Outlet />
</Layout>
</Authenticated>
}>
<Route path='*' element={<ErrorComponent />} />
</Route>
</Routes>
<UnsavedChangesNotifier />
<DocumentTitleHandler />
<Toaster position='bottom-right' reverseOrder={false} />
<DevtoolsPanel />
</Refine>
</DevtoolsProvider>
</ThemeProvider>
</BrowserRouter>
)
}
export default App
添加这些更改后,您可以导航至/manager/requests
路由,查看请假请求管理页面的操作情况

构建请假请求详情页面
在这一步中,我们将创建一个新页面来展示请假请求的详情。这个页面将显示员工姓名、请假类型、请求日期和当前状态。经理可以在此页面批准或拒绝请求。
构建<TimeOffRequestModal />
组件
首先,在src/hooks/
文件夹中创建一个名为use-get-employee-time-off-usage
的文件,并添加以下代码:
import { useList } from "@refinedev/core";
import { type TimeOff, TimeOffStatus, TimeOffType } from "@/types";
import { useMemo } from "react";
import dayjs from "dayjs";
export const useGetEmployeeTimeOffUsage = ({
employeeId,
}: { employeeId?: number }) => {
const query = useList<TimeOff>({
resource: "time-offs",
pagination: { pageSize: 999 },
filters: [
{
field: "status",
operator: "eq",
value: TimeOffStatus.APPROVED,
},
{
field: "employeeId",
operator: "eq",
value: employeeId,
},
],
queryOptions: {
enabled: !!employeeId,
},
});
const data = query?.data?.data;
const { sick, casual, annual, sickCount, casualCount, annualCount } =
useMemo(() => {
const sick: TimeOff[] = [];
const casual: TimeOff[] = [];
const annual: TimeOff[] = [];
let sickCount = 0;
let casualCount = 0;
let annualCount = 0;
data?.forEach((timeOff) => {
const duration =
dayjs(timeOff.endsAt).diff(dayjs(timeOff.startsAt), "days") + 1;
if (timeOff.timeOffType === TimeOffType.SICK) {
sick.push(timeOff);
sickCount += duration;
} else if (timeOff.timeOffType === TimeOffType.CASUAL) {
casual.push(timeOff);
casualCount += duration;
} else if (timeOff.timeOffType === TimeOffType.ANNUAL) {
annual.push(timeOff);
annualCount += duration;
}
});
return {
sick,
casual,
annual,
sickCount,
casualCount,
annualCount,
};
}, [data]);
return {
query,
sick,
casual,
annual,
sickCount,
casualCount,
annualCount,
};
};
我们将使用useGetEmployeeTimeOffUsage
钩子来计算员工每种请假类型已请假的总天数。这些信息将显示在请假请求详情页面中。
之后,在src/components/requests/
文件夹中创建一个名为time-off-request-modal.tsx
的新文件,并添加以下代码:
import type { ReactNode } from "react";
import { useInvalidate, useList, useUpdate } from "@refinedev/core";
import {
Avatar,
Box,
Button,
Divider,
Tooltip,
Typography,
} from "@mui/material";
import dayjs from "dayjs";
import { Modal } from "@/components/modal";
import {
TimeOffStatus,
TimeOffType,
type Employee,
type TimeOff,
} from "@/types";
import { RequestTypeIcon, ThumbsDownIcon, ThumbsUpIcon } from "@/icons";
import { useGetEmployeeTimeOffUsage } from "@/hooks/use-get-employee-time-off-usage";
type Props = {
open: boolean;
onClose: () => void;
loading: boolean;
onSuccess?: () => void;
timeOff:
| (TimeOff & {
employee: Employee;
})
| null
| undefined;
};
export const TimeOffRequestModal = ({
open,
timeOff,
loading: loadingFromProps,
onClose,
onSuccess,
}: Props) => {
const employeeUsedTimeOffs = useGetEmployeeTimeOffUsage({
employeeId: timeOff?.employee.id,
});
const invalidate = useInvalidate();
const { mutateAsync } = useUpdate<TimeOff>();
const employee = timeOff?.employee;
const duration =
dayjs(timeOff?.endsAt).diff(dayjs(timeOff?.startsAt), "days") + 1;
const remainingAnnualLeaveDays =
(employee?.availableAnnualLeaveDays ?? 0) - duration;
const { data: timeOffsData, isLoading: timeOffsLoading } = useList<
TimeOff & { employee: Employee }
>({
resource: "time-offs",
pagination: {
pageSize: 999,
},
filters: [
{
field: "status",
operator: "eq",
value: TimeOffStatus.APPROVED,
},
{
operator: "and",
value: [
{
field: "startsAt",
operator: "lte",
value: timeOff?.endsAt,
},
{
field: "endsAt",
operator: "gte",
value: timeOff?.startsAt,
},
],
},
],
queryOptions: {
enabled: !!timeOff,
},
meta: {
join: ["employee"],
},
});
const whoIsOutList = timeOffsData?.data || [];
const handleSubmit = async (status: TimeOffStatus) => {
await mutateAsync({
resource: "time-offs",
id: timeOff?.id,
invalidates: ["resourceAll"],
values: {
status,
},
});
onSuccess?.();
invalidate({
resource: "employees",
invalidates: ["all"],
});
};
const loading = timeOffsLoading || loadingFromProps;
return (
<Modal
open={open}
title="Time Off Request"
loading={loading}
sx={{
maxWidth: "520px",
}}
onClose={onClose}
footer={
<>
<Divider />
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "8px",
padding: "24px",
}}
>
<Button
sx={{
backgroundColor: (theme) => theme.palette.error.light,
}}
startIcon={<ThumbsDownIcon />}
onClick={() => handleSubmit(TimeOffStatus.REJECTED)}
>
Decline
</Button>
<Button
sx={{
backgroundColor: (theme) => theme.palette.success.light,
}}
onClick={() => handleSubmit(TimeOffStatus.APPROVED)}
startIcon={<ThumbsUpIcon />}
>
Accept
</Button>
</Box>
</>
}
>
<Box
sx={{
display: "flex",
alignItems: "center",
padding: "24px",
backgroundColor: (theme) => theme.palette.grey[50],
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
}}
>
<Avatar
src={employee?.avatarUrl}
alt={employee?.firstName}
sx={{
width: "80px",
height: "80px",
marginRight: "24px",
}}
/>
<Box
sx={{
display: "flex",
flexDirection: "column",
}}
>
<Typography
variant="h2"
fontSize="18px"
lineHeight="28px"
fontWeight="500"
>
{employee?.firstName} {employee?.lastName}
</Typography>
<Typography variant="caption">{employee?.jobTitle}</Typography>
<Typography variant="caption">{employee?.role}</Typography>
</Box>
</Box>
<Box
sx={{
padding: "24px",
}}
>
<InfoRow
loading={loading}
label="Request Type"
value={
<Box
component="span"
sx={{
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<RequestTypeIcon type={timeOff?.timeOffType} />
<Typography variant="body2" component="span">
{timeOff?.timeOffType} Leave
</Typography>
</Box>
}
/>
<Divider />
<InfoRow
loading={loading}
label="Duration"
value={`${duration > 1 ? `${duration} days` : `${duration} day`}`}
/>
<Divider />
<InfoRow
loading={loading}
label={
{
[TimeOffType.ANNUAL]: "Remaining Annual Leave Days",
[TimeOffType.SICK]: "Previously Used Sick Leave Days",
[TimeOffType.CASUAL]: "Previously Used Casual Leave Days",
}[timeOff?.timeOffType ?? TimeOffType.ANNUAL]
}
value={
{
[TimeOffType.ANNUAL]: remainingAnnualLeaveDays,
[TimeOffType.SICK]: employeeUsedTimeOffs.sickCount,
[TimeOffType.CASUAL]: employeeUsedTimeOffs.casualCount,
}[timeOff?.timeOffType ?? TimeOffType.ANNUAL]
}
/>
<Divider />
<InfoRow
loading={loading}
label="Start Date"
value={dayjs(timeOff?.startsAt).format("MMMM DD")}
/>
<Divider />
<InfoRow
loading={loading}
label="End Date"
value={dayjs(timeOff?.endsAt).format("MMMM DD")}
/>
<Divider />
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "8px",
paddingY: "24px",
}}
>
<Typography variant="body2" fontWeight={600}>
Notes
</Typography>
<Typography
variant="body2"
sx={{
height: "20px",
fontStyle: timeOff?.notes ? "normal" : "italic",
}}
>
{!loading && (timeOff?.notes || "No notes provided.")}
</Typography>
</Box>
<Divider />
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "8px",
paddingY: "24px",
}}
>
<Typography variant="body2" fontWeight={600}>
Who's out between these days?
</Typography>
<Box
sx={{
display: "flex",
alignItems: "center",
flexWrap: "wrap",
gap: "8px",
}}
>
{whoIsOutList.length ? (
whoIsOutList.map((whoIsOut) => (
<Tooltip
key={whoIsOut.id}
sx={{
"& .MuiTooltip-tooltip": {
background: "red",
},
}}
title={
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "2px",
}}
>
<Typography variant="body2">
{whoIsOut.employee.firstName}{" "}
{whoIsOut.employee.lastName}
</Typography>
<Typography variant="caption">
{whoIsOut.timeOffType} Leave
</Typography>
<Typography variant="caption">
{dayjs(whoIsOut.startsAt).format("MMMM DD")} -{" "}
{dayjs(whoIsOut.endsAt).format("MMMM DD")}
</Typography>
</Box>
}
placement="top"
>
<Avatar
src={whoIsOut.employee.avatarUrl}
alt={whoIsOut.employee.firstName}
sx={{
width: "32px",
height: "32px",
}}
/>
</Tooltip>
))
) : (
<Typography
variant="body2"
sx={{
height: "32px",
fontStyle: "italic",
}}
>
{loading ? "" : "No one is out between these days."}
</Typography>
)}
</Box>
</Box>
</Box>
</Modal>
);
};
const InfoRow = ({
label,
value,
loading,
}: { label: ReactNode; value: ReactNode; loading: boolean }) => {
return (
<Box
sx={{
display: "flex",
justifyContent: "space-between",
paddingY: "24px",
height: "72px",
}}
>
<Typography variant="body2">{label}</Typography>
<Typography variant="body2">{loading ? "" : value}</Typography>
</Box>
);
};
让我们分解一下<TimeOffRequestModal />
组件:
1. 获取员工请假使用情况
useGetEmployeeTimeOffUsage
钩子用于获取员工的请假使用情况。该钩子根据员工的请假历史计算剩余的年度假期天数以及之前使用的病假和事假天数。
2. 获取重叠的已批准请假
filters: [
{
field: "status",
operator: "eq",
value: TimeOffStatus.APPROVED,
},
{
operator: "and",
value: [
{
field: "startsAt",
operator: "lte",
value: timeOff?.endsAt,
},
{
field: "endsAt",
operator: "gte",
value: timeOff?.startsAt,
},
],
},
];
使用上述过滤器的 useList
钩子获取与当前请假请求重叠的所有已批准请假。这份列表用于显示在请求日期之间缺勤的员工。
3. 处理请假请求的批准/拒绝
当经理批准或拒绝请假请求时,会调用 handleSubmit
函数。
const invalidate = useInvalidate();
// ...
const handleSubmit = async (status: TimeOffStatus) => {
await mutateAsync({
resource: "time-offs",
id: timeOff?.id,
invalidates: ["resourceAll"],
values: {
status,
},
});
onSuccess?.();
invalidate({
resource: "employees",
invalidates: ["all"],
});
};
Refine 会在资源被修改后自动使资源缓存失效(在这种情况下是 time-offs
)。由于员工的请假使用情况是根据请假历史计算的,我们还会使 employees
资源缓存失效,以更新员工的请假使用情况。
添加“/manager/requests/:id” 路由
在这一步,我们将创建一个新路由以显示请假请求详情页面,经理可以在此批准或拒绝请求。
我们在 src/pages/manager/requests/time-offs/
文件夹中创建一个名为 edit.tsx
的新文件,并添加以下代码:
import { useGo, useShow } from "@refinedev/core";
import { TimeOffRequestModal } from "@/components/requests/time-off-request-modal";
import type { Employee, TimeOff } from "@/types";
export const PageManagerRequestsTimeOffsEdit = () => {
const go = useGo();
const { query: timeOffRequestQuery } = useShow<
TimeOff & { employee: Employee }
>({
meta: {
join: ["employee"],
},
});
const loading = timeOffRequestQuery.isLoading;
return (
<TimeOffRequestModal
open
loading={loading}
timeOff={timeOffRequestQuery?.data?.data}
onClose={() =>
go({
to: {
resource: "requests",
action: "list",
},
})
}
onSuccess={() => {
go({
to: {
resource: "requests",
action: "list",
},
});
}}
/>
);
};
现在我们需要添加新路由来渲染请假请求详情页面。让我们更新 App.tsx
文件以包含此路由:
import { Authenticated, ErrorComponent, Refine } from '@refinedev/core'
import { DevtoolsProvider, DevtoolsPanel } from '@refinedev/devtools'
import dataProvider from '@refinedev/nestjsx-crud'
import routerProvider, {
UnsavedChangesNotifier,
DocumentTitleHandler,
NavigateToResource,
} from '@refinedev/react-router-v6'
import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import { PageEmployeeTimeOffsList } from '@/pages/employee/time-offs/list'
import { PageEmployeeTimeOffsCreate } from '@/pages/employee/time-offs/create'
import { PageManagerRequestsList } from '@/pages/manager/requests/list'
import { PageManagerRequestsTimeOffsEdit } from '@/pages/manager/requests/time-offs/edit'
import { PageLogin } from '@/pages/login'
import { Layout } from '@/components/layout'
import { ThemeProvider } from '@/providers/theme-provider'
import { authProvider } from '@/providers/auth-provider'
import { accessControlProvider } from '@/providers/access-control'
import { useNotificationProvider } from '@/providers/notification-provider'
import { queryClient } from '@/providers/query-client'
import { BASE_URL } from '@/utilities/constants'
import { axiosInstance } from '@/utilities/axios'
import { RequestsIcon, TimeOffIcon } from '@/icons'
import { Role } from '@/types'
import '@/utilities/init-dayjs'
function App() {
return (
<BrowserRouter>
<ThemeProvider>
<DevtoolsProvider>
<Refine
authProvider={authProvider}
routerProvider={routerProvider}
dataProvider={dataProvider(BASE_URL, axiosInstance)}
notificationProvider={useNotificationProvider}
resources={[
{
name: 'employee',
meta: {
scope: Role.EMPLOYEE,
order: 2,
},
},
{
name: 'manager',
meta: {
scope: Role.MANAGER,
order: 1,
},
},
{
name: 'time-offs',
list: '/employee/time-offs',
create: '/employee/time-offs/new',
meta: {
parent: 'employee',
scope: Role.EMPLOYEE,
label: 'Time Off',
icon: <TimeOffIcon />,
},
},
{
name: 'time-offs',
list: '/manager/requests',
edit: '/manager/requests/:id/edit',
identifier: 'requests',
meta: {
parent: 'manager',
scope: Role.MANAGER,
label: 'Requests',
icon: <RequestsIcon />,
},
},
]}
accessControlProvider={accessControlProvider}
options={{
reactQuery: {
clientConfig: queryClient,
},
syncWithLocation: true,
warnWhenUnsavedChanges: true,
useNewQueryKeys: true,
}}>
<Routes>
<Route
element={
<Authenticated key='authenticated-routes' redirectOnFail='/login'>
<Outlet />
</Authenticated>
}>
<Route index element={<NavigateToResource resource='time-offs' />} />
<Route
path='employee'
element={
<ThemeProvider role={Role.EMPLOYEE}>
<Layout>
<Outlet />
</Layout>
</ThemeProvider>
}>
<Route path='time-offs' element={<Outlet />}>
<Route index element={<PageEmployeeTimeOffsList />} />
<Route path='new' element={<PageEmployeeTimeOffsCreate />} />
</Route>
</Route>
</Route>
<Route
path='manager'
element={
<ThemeProvider role={Role.MANAGER}>
<Layout>
<Outlet />
</Layout>
</ThemeProvider>
}>
<Route
path='requests'
element={
<PageManagerRequestsList>
<Outlet />
</PageManagerRequestsList>
}>
<Route path=':id/edit' element={<PageManagerRequestsTimeOffsEdit />} />
</Route>
</Route>
<Route
element={
<Authenticated key='auth-pages' fallback={<Outlet />}>
<NavigateToResource resource='time-offs' />
</Authenticated>
}>
<Route path='/login' element={<PageLogin />} />
</Route>
<Route
element={
<Authenticated key='catch-all'>
<Layout>
<Outlet />
</Layout>
</Authenticated>
}>
<Route path='*' element={<ErrorComponent />} />
</Route>
</Routes>
<UnsavedChangesNotifier />
<DocumentTitleHandler />
<Toaster position='bottom-right' reverseOrder={false} />
<DevtoolsPanel />
</Refine>
</DevtoolsProvider>
</ThemeProvider>
</BrowserRouter>
)
}
export default App
让我们仔细看看这些更改:
<Route
path="requests"
element={
<PageManagerRequestsList>
<Outlet />
</PageManagerRequestsList>
}
>
<Route path=":id/edit" element={<PageManagerRequestsTimeOffsEdit />} />
</Route>
上面的代码设置了一个嵌套的路由结构,在导航到特定的子路由时显示一个模态框。<PageManagerRequestsTimeOffsEdit />
组件是一个模态框,作为<PageManagerRequestsList />
组件的子组件呈现。这种结构允许我们在列表页面的背景上方显示模态框,同时保持列表页面可见。
当您导航到/manager/requests/:id/edit
路由或在列表中点击一个请假请求时,将在列表页面的上方显示请假请求详情页面作为模态框。

第六步 – 实现授权和访问控制
授权是企业级应用程序中的关键组成部分,在安全性和运营效率方面发挥着重要作用。它确保只有经过授权的用户可以访问特定资源,保护敏感数据和功能。Refine的授权系统提供了必要的基础设施,保护您的资源,并确保用户以安全和受控的方式与应用程序交互。在这一步中,我们将为调休请求管理功能实现授权和访问控制。我们将通过<CanAccess />
组件,将对/manager/requests
和/manager/requests/:id/edit
路由的访问限制为仅限经理用户。
目前,当您以员工身份登录时,侧边栏中看不到请求
页面链接,但仍然可以通过在浏览器中输入URL来访问/manager/requests
路由。我们将添加一个保护措施,防止未经授权的访问这些路由。
让我们更新App.tsx
文件以包含授权检查:
import { Authenticated, CanAccess, ErrorComponent, Refine } from '@refinedev/core'
import { DevtoolsProvider, DevtoolsPanel } from '@refinedev/devtools'
import dataProvider from '@refinedev/nestjsx-crud'
import routerProvider, {
UnsavedChangesNotifier,
DocumentTitleHandler,
NavigateToResource,
} from '@refinedev/react-router-v6'
import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import { PageEmployeeTimeOffsList } from '@/pages/employee/time-offs/list'
import { PageEmployeeTimeOffsCreate } from '@/pages/employee/time-offs/create'
import { PageManagerRequestsList } from '@/pages/manager/requests/list'
import { PageManagerRequestsTimeOffsEdit } from '@/pages/manager/requests/time-offs/edit'
import { PageLogin } from '@/pages/login'
import { Layout } from '@/components/layout'
import { ThemeProvider } from '@/providers/theme-provider'
import { authProvider } from '@/providers/auth-provider'
import { accessControlProvider } from '@/providers/access-control'
import { useNotificationProvider } from '@/providers/notification-provider'
import { queryClient } from '@/providers/query-client'
import { BASE_URL } from '@/utilities/constants'
import { axiosInstance } from '@/utilities/axios'
import { RequestsIcon, TimeOffIcon } from '@/icons'
import { Role } from '@/types'
import '@/utilities/init-dayjs'
function App() {
return (
<BrowserRouter>
<ThemeProvider>
<DevtoolsProvider>
<Refine
authProvider={authProvider}
routerProvider={routerProvider}
dataProvider={dataProvider(BASE_URL, axiosInstance)}
notificationProvider={useNotificationProvider}
resources={[
{
name: 'employee',
meta: {
scope: Role.EMPLOYEE,
order: 2,
},
},
{
name: 'manager',
meta: {
scope: Role.MANAGER,
order: 1,
},
},
{
name: 'time-offs',
list: '/employee/time-offs',
create: '/employee/time-offs/new',
meta: {
parent: 'employee',
scope: Role.EMPLOYEE,
label: 'Time Off',
icon: <TimeOffIcon />,
},
},
{
name: 'time-offs',
list: '/manager/requests',
edit: '/manager/requests/:id/edit',
identifier: 'requests',
meta: {
parent: 'manager',
scope: Role.MANAGER,
label: 'Requests',
icon: <RequestsIcon />,
},
},
]}
accessControlProvider={accessControlProvider}
options={{
reactQuery: {
clientConfig: queryClient,
},
syncWithLocation: true,
warnWhenUnsavedChanges: true,
useNewQueryKeys: true,
}}>
<Routes>
<Route
element={
<Authenticated key='authenticated-routes' redirectOnFail='/login'>
<Outlet />
</Authenticated>
}>
<Route index element={<NavigateToResource resource='time-offs' />} />
<Route
path='employee'
element={
<ThemeProvider role={Role.EMPLOYEE}>
<Layout>
<Outlet />
</Layout>
</ThemeProvider>
}>
<Route path='time-offs' element={<Outlet />}>
<Route index element={<PageEmployeeTimeOffsList />} />
<Route path='new' element={<PageEmployeeTimeOffsCreate />} />
</Route>
</Route>
</Route>
<Route
path='manager'
element={
<ThemeProvider role={Role.MANAGER}>
<Layout>
<CanAccess action='manager' fallback={<NavigateToResource resource='time-offs' />}>
<Outlet />
</CanAccess>
</Layout>
</ThemeProvider>
}>
<Route
path='requests'
element={
<PageManagerRequestsList>
<Outlet />
</PageManagerRequestsList>
}>
<Route path=':id/edit' element={<PageManagerRequestsTimeOffsEdit />} />
</Route>
</Route>
<Route
element={
<Authenticated key='auth-pages' fallback={<Outlet />}>
<NavigateToResource resource='time-offs' />
</Authenticated>
}>
<Route path='/login' element={<PageLogin />} />
</Route>
<Route
element={
<Authenticated key='catch-all'>
<Layout>
<Outlet />
</Layout>
</Authenticated>
}>
<Route path='*' element={<ErrorComponent />} />
</Route>
</Routes>
<UnsavedChangesNotifier />
<DocumentTitleHandler />
<Toaster position='bottom-right' reverseOrder={false} />
<DevtoolsPanel />
</Refine>
</DevtoolsProvider>
</ThemeProvider>
</BrowserRouter>
)
}
export default App
在上面的代码中,我们向“/manager”路由添加了<CanAccess />
组件。该组件在呈现子路由之前检查用户是否具有“manager”角色。如果用户没有“manager”角色,它们将被重定向到员工的调休列表页面。
现在,当您以员工身份登录并尝试访问 /manager/requests
路由时,您将被重定向到员工的请假列表页面。
第7步 — 部署到DigitalOcean应用平台
在此步骤中,我们将应用程序部署到DigitalOcean应用平台。为此,我们将在GitHub上托管源代码,并将GitHub存储库连接到应用平台。
推送代码到GitHub
登录到您的GitHub账户,并创建一个名为 refine-hr
的新存储库。您可以选择将存储库设为公开或私有:
创建存储库后,导航到项目目录并运行以下命令以初始化一个新的Git存储库:
git init
接下来,使用此命令将所有文件添加到Git存储库中:
git add .
然后,使用此命令提交文件:
git commit -m "Initial commit"
接下来,使用此命令将GitHub存储库添加为远程存储库:
git remote add origin <your-github-repository-url>
接下来,使用此命令指定要将代码推送到 main
分支:
git branch -M main
最后,使用以下命令将代码推送到GitHub存储库:
git push -u origin main
在提示时,输入您的GitHub凭据以推送代码。
代码推送到GitHub存储库后,您将收到一个成功消息。
在这一部分,您将项目推送到GitHub,以便您可以使用DigitalOcean Apps访问它。下一步是使用您的项目创建一个新的DigitalOcean App,并设置自动部署。
部署到DigitalOcean App平台
在此过程中,您将获取一个React应用程序并准备通过DigitalOcean的App平台进行部署。您将把您的GitHub存储库链接到DigitalOcean,配置应用程序的构建方式,然后创建一个项目的初始部署。项目部署后,您进行的额外更改将自动重建和更新。
通过本步骤结束时,您的应用程序将已经部署在DigitalOcean上,并且连续交付已经考虑在内。
登录到您的DigitalOcean账户,并导航到Apps页面。点击创建App按钮:
如果您尚未将 GitHub 账户连接到 DigitalOcean,系统会提示您这样做。点击 连接到 GitHub 按钮。将打开一个新窗口,请您授权 DigitalOcean 访问您的 GitHub 账户。
授权 DigitalOcean 后,您将被重定向回 DigitalOcean 应用页面。下一步是选择您的 GitHub 仓库。在您选择仓库后,系统会提示您选择要部署的分支。选择 main
分支并点击 下一步 按钮。
之后,您将看到应用的配置步骤。在本教程中,您可以点击 下一步 按钮跳过配置步骤。不过,您也可以根据自己的需要配置应用。
等待构建完成。构建完成后,按 实时应用 在浏览器中访问您的项目。这将与您本地测试的项目相同,但将在网络上以安全的 URL 形式呈现。此外,您可以参考 DigitalOcean 社区网站上的本教程,学习如何将基于 React 的应用部署到 App Platform。
注意:如果构建未能成功部署,您可以在 DigitalOcean 上配置构建命令为 npm install --production=false && npm run build && npm prune --production
,而不是 npm run build
。
结论
在本教程中,我们从零开始使用Refine构建了一个人力资源管理应用,并熟悉了如何构建一个完全功能的CRUD应用。
此外,我们将演示如何将您的应用部署到DigitalOcean 应用平台。
如果您想了解更多关于Refine的信息,可以查阅文档,如果您有任何问题或反馈,欢迎加入Refine Discord 服务器。