Skip to content
Getting Started

用 Refine 构建一个用户管理应用

本教程演示了如何构建一个基本的用户管理应用。该应用可以进行用户认证和识别,将用户的个人资料信息存储在数据库中,并允许用户登录、更新他们的个人资料信息以及上传头像。该应用使用了:

🌐 This tutorial demonstrates how to build a basic user management app. The app authenticates and identifies the user, stores their profile information in the database, and allows the user to log in, update their profile details, and upload a profile photo. The app uses:

Supabase User Management example

关于Refine #

🌐 About Refine

Refine 是一个基于 React 的框架,用于快速构建数据密集型应用,比如管理面板、仪表盘、商店界面以及各种 CRUD 应用。它将应用的关注点分离到不同的层,每一层都有一个 React context 和相应的提供者对象。例如,认证层代表了一个由特定 authProvider 方法集提供的 context,这些方法执行认证和授权操作,比如登录、登出、获取角色数据等。同样,数据层提供了另一个抽象层,配备 dataProvider 方法来处理适当后端 API 端点的 CRUD 操作。

Refine 通过它的附加 @refinedev/supabase 包提供了与 Supabase 后端的无忧集成。它会在项目初始化时生成 authProviderdataProvider 方法,所以你不需要花太多精力自己去定义,在使用 create refine-app 创建应用时选择 Supabase 作为后端服务即可。

🌐 Refine provides hassle-free integration with a Supabase backend with its supplementary @refinedev/supabase package. It generates authProvider and dataProvider methods at project initialization, so you don't need to spend much effort defining them yourself, choose Supabase as the backend service while creating the app with create refine-app.

项目设置 #

🌐 Project setup

在你开始构建之前,你需要先设置数据库和 API。你可以通过在 Supabase 中启动一个新项目,然后在数据库中创建一个“架构”来完成这一步。

🌐 Before you start building you need to set up the Database and API. You can do this by starting a new Project in Supabase and then creating a "schema" inside the database.

创建一个项目 #

🌐 Create a project

  1. 在 Supabase 仪表板中创建一个新项目
  2. 输入你的项目详情。
  3. 等新数据库上线。

设置数据库模式 #

🌐 Set up the database schema

现在设置数据库模式。你可以在 SQL 编辑器中使用“用户管理入门”快速开始,也可以复制粘贴下面的 SQL 并运行。

🌐 Now set up the database schema. You can use the "User Management Starter" quickstart in the SQL Editor, or you can copy/paste the SQL from below and run it.

  1. 在仪表板中转到SQL 编辑器页面。
  2. 点击 社区 > 快速入门 标签下的 用户管理入门
  3. 点击运行

获取 API 详情 #

🌐 Get API details

要与数据库表中的数据进行交互,你可以使用封装了自动生成的数据 API 端点的客户端库,并使用来自项目 Connect 对话框的项目 URL 和密钥进行认证。

🌐 To interact with data in database tables, you use the client libraries that wrap the auto-generated Data API endpoints, authenticating using the Project URL and key from the project Connect dialog.

Project URL
Publishable key

正在构建应用 #

🌐 Building the app

从零开始构建 Refine 应用。

🌐 Start building the Refine app from scratch.

初始化一个 Refine 应用 #

🌐 Initialize a Refine app

使用 create refine-app 命令来初始化一个应用。在终端运行以下命令:

🌐 Use create refine-app command to initialize an app. Run the following in the terminal:

1
npm create refine-app@latest -- --preset refine-supabase

上面的命令使用了 refine-supabase 预设,它为应用选择了 Supabase 补充包。没有 UI 框架,所以应用是无头 UI,只有普通的 React 和 CSS 样式。

🌐 The command above uses the refine-supabase preset which chooses the Supabase supplementary package for the app. There's no UI framework, so the app has a headless UI with plain React and CSS styling.

refine-supabase 预设会安装 @refinedev/supabase 包,它开箱即用就包含了 Supabase 依赖:supabase-js

🌐 The refine-supabase preset installs the @refinedev/supabase package which out-of-the-box includes the Supabase dependency: supabase-js.

安装 @refinedev/react-hook-formreact-hook-form 包,以便在 Refine 应用中使用 React Hook Form。运行:

🌐 Install the @refinedev/react-hook-form and react-hook-form packages that to use React Hook Form inside Refine apps. Run:

1
npm install @refinedev/react-hook-form react-hook-form

Refine supabaseClient#

create refine-appsrc/utility/supabaseClient.ts 文件中生成了一个 Supabase 客户端。它有两个常量:SUPABASE_URLSUPABASE_KEY。分别将它们替换为 supabaseUrlsupabasePublishableKey,并分配你 Supabase 服务器的值。

🌐 The create refine-app generated a Supabase client in the src/utility/supabaseClient.ts file. It has two constants: SUPABASE_URL and SUPABASE_KEY. Replace them as supabaseUrl and supabasePublishableKey respectively and assign them your Supabase server's values.

用 Vite 管理的环境变量来更新它:

🌐 Update it with environment variables managed by Vite:

src/utility/supabaseClient.ts
1
import { createClient } from '@refinedev/supabase'
2
3
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
4
const supabasePublishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
5
6
export const supabaseClient = createClient(supabaseUrl, supabasePublishableKey, {
7
db: {
8
schema: 'public',
9
},
10
auth: {
11
persistSession: true,
12
},
13
})
View source

将环境变量保存到 .env.local 文件中。你只需要之前复制的 API URL 和密钥 链接 就可以了。

🌐 Save the environment variables in a .env.local file. All you need are the API URL and the key that you copied earlier.

1
VITE_SUPABASE_URL=YOUR_SUPABASE_URL
2
VITE_SUPABASE_PUBLISHABLE_KEY=YOUR_SUPABASE_PUBLISHABLE_KEY

supabaseClient 从应用中获取对 Supabase 端点的调用。这个客户端在使用 Refine 的认证提供方法来实现认证,以及使用合适的数据提供方法进行 CRUD 操作方面非常重要。

🌐 The supabaseClient fetches calls to Supabase endpoints from the app. The client is instrumental in implementing authentication using Refine's auth provider methods and CRUD actions with appropriate data provider methods.

应用风格(可选) #

🌐 App styling (optional)

一个可选步骤是更新 CSS 文件 src/App.css,让应用看起来更好。你可以在示例仓库中找到这个文件的完整内容。

🌐 An optional step is to update the CSS file src/App.css to make the app look better. You can find the full contents of this file in the example repository.

<Refine />#

🌐 The <Refine /> component

为了在这个应用中添加登录和用户个人资料页面,修改 App.tsx 内的 <Refine /> 组件即可。

🌐 In order to add login and user profile pages in this App, tweak the <Refine /> component inside App.tsx.

App.tsx 文件最初看起来是这样的:

🌐 The App.tsx file initially looks like this:

1
import { Refine, WelcomePage } from '@refinedev/core'
2
import { RefineKbar, RefineKbarProvider } from '@refinedev/kbar'
3
import routerProvider, {
4
DocumentTitleHandler,
5
UnsavedChangesNotifier,
6
} from '@refinedev/react-router'
7
import { dataProvider, liveProvider } from '@refinedev/supabase'
8
import { BrowserRouter, Route, Routes } from 'react-router'
9
import './App.css'
10
import authProvider from './authProvider'
11
import { supabaseClient } from './utility'
12
13
function App() {
14
return (
15
<BrowserRouter>
16
<RefineKbarProvider>
17
<Refine
18
dataProvider={dataProvider(supabaseClient)}
19
liveProvider={liveProvider(supabaseClient)}
20
authProvider={authProvider}
21
routerProvider={routerProvider}
22
options={{
23
syncWithLocation: true,
24
warnWhenUnsavedChanges: true,
25
}}
26
>
27
<Routes>
28
<Route index element={<WelcomePage />} />
29
</Routes>
30
<RefineKbar />
31
<UnsavedChangesNotifier />
32
<DocumentTitleHandler />
33
</Refine>
34
</RefineKbarProvider>
35
</BrowserRouter>
36
)
37
}
38
39
export default App

关注 <Refine /> 组件,它会接收传递给它的 props。注意 dataProvider prop。它使用 dataProvider() 函数,并将 supabaseClient 作为参数传入来生成数据提供者对象。authProvider 对象在实现方法时也使用了 supabaseClient。你可以在 src/authProvider.ts 文件中查看它。

🌐 Focus on the <Refine /> component, which comes with props passed to it. Notice the dataProvider prop. It uses a dataProvider() function with supabaseClient passed as argument to generate the data provider object. The authProvider object also uses supabaseClient in implementing its methods. You can look it up in src/authProvider.ts file.

自定义 authProvider#

🌐 Customize authProvider

如果你查看 authProvider 对象,你会注意到它有一个 login 方法,用来实现 OAuth 和邮箱/密码认证策略。不过这个教程则移除了它们,改用魔法链接,让用户可以通过邮箱登录而不需要密码。

🌐 If you examine the authProvider object you can notice that it has a login method that implements an OAuth and Email / Password strategy for authentication. This tutorial instead removes them and use Magic Links to allow users sign in with their email without using passwords.

authProvider.login 方法中使用 supabaseClient 授权的 signInWithOtp 方法:

🌐 Use supabaseClient auth's signInWithOtp method inside authProvider.login method:

src/authProvider.ts
1
login: async ({ email }) => {
2
try {
3
const { error } = await supabaseClient.auth.signInWithOtp({ email });
4
5
if (!error) {
6
alert("Check your email for the login link!");
7
return {
8
success: true,
9
};
10
};
11
12
throw error;
13
} catch (e: any) {
14
alert(e.message);
15
return {
16
success: false,
17
e,
18
};
19
}
20
},

移除 registerupdatePasswordforgotPasswordgetPermissions 属性,它们是可选类型成员,而且对应用并不必要。最终的 authProvider 对象看起来是这样的:

🌐 Remove register, updatePassword, forgotPassword and getPermissions properties, which are optional type members and also not necessary for the app. The final authProvider object looks like this:

src/authProvider.ts
1
import { AuthProvider } from '@refinedev/core'
2
3
import { supabaseClient } from './utility'
4
5
const authProvider: AuthProvider = {
6
login: async ({ email }) => {
7
try {
8
const { error } = await supabaseClient.auth.signInWithOtp({ email })
9
10
if (!error) {
11
alert('Check your email for the login link!')
12
return {
13
success: true,
14
}
15
}
16
17
throw error
18
} catch (e: any) {
19
alert(e.message)
20
return {
21
success: false,
22
e,
23
}
24
}
25
},
26
logout: async () => {
27
const { error } = await supabaseClient.auth.signOut()
28
29
if (error) {
30
return {
31
success: false,
32
error,
33
}
34
}
35
36
return {
37
success: true,
38
redirectTo: '/',
39
}
40
},
41
onError: async (error) => {
42
console.error(error)
43
return { error }
44
},
45
check: async () => {
46
try {
47
const { data, error } = await supabaseClient.auth.getClaims()
48
49
if (error || !data) {
50
return {
51
authenticated: false,
52
error: {
53
message: 'Check failed',
54
name: 'Session not found',
55
},
56
logout: true,
57
redirectTo: '/login',
58
}
59
}
60
} catch (error: any) {
61
return {
62
authenticated: false,
63
error: error || {
64
message: 'Check failed',
65
name: 'Not authenticated',
66
},
67
logout: true,
68
redirectTo: '/login',
69
}
70
}
71
72
return {
73
authenticated: true,
74
}
75
},
76
getIdentity: async () => {
77
const { data } = await supabaseClient.auth.getUser()
78
79
if (data?.user) {
80
return {
81
...data.user,
82
name: data.user.email,
83
}
84
}
85
86
return null
87
},
88
}
89
90
export default authProvider
View source

设置一个登录组件 #

🌐 Set up a login component

由于该应用使用的是不带支持 UI 框架的无头 Refine 核心包,所以可以建立一个普通的 React 组件来管理登录和注册。

🌐 As the app uses the headless Refine core package that comes with no supported UI framework set up a plain React component to manage logins and sign ups.

创建并编辑 src/components/auth.tsx

🌐 Create and edit src/components/auth.tsx:

src/components/auth.tsx
1
import { useState } from 'react'
2
3
import { useLogin } from '@refinedev/core'
4
5
export default function Auth() {
6
const [email, setEmail] = useState('')
7
const { isPending, mutate: login } = useLogin()
8
9
const handleLogin = async (event: { preventDefault: () => void }) => {
10
event.preventDefault()
11
login({ email })
12
}
13
14
return (
15
<div className="row flex flex-center container">
16
<div className="col-6 form-widget">
17
<h1 className="header">Supabase + Refine</h1>
18
<p className="description">Sign in via magic link with your email below</p>
19
<form className="form-widget" onSubmit={handleLogin}>
20
<div>
21
<input
22
className="inputField"
23
type="email"
24
placeholder="Your email"
25
value={email}
26
required={true}
27
onChange={(e) => setEmail(e.target.value)}
28
/>
29
</div>
30
<div>
31
<button className={'button block'} disabled={isPending}>
32
{isPending ? <span>Loading</span> : <span>Send magic link</span>}
33
</button>
34
</div>
35
</form>
36
</div>
37
</div>
38
)
39
}
View source

useLogin() Refine 认证钩子,用来获取 mutate: login 方法,以便在 handleLogin() 函数和 isLoading 状态中用于表单提交。useLogin() 钩子很方便地提供了 authProvider.login 方法,用于通过 OTP 验证用户身份。

🌐 The useLogin() Refine auth hook to grab the mutate: login method to use inside handleLogin() function and isLoading state for the form submission. The useLogin() hook conveniently offers access to authProvider.login method for authenticating the user with OTP.

账户页面 #

🌐 Account page

用户登录后,允许他们编辑个人资料和管理账户。

🌐 After a user is signed in, allow them to edit their profile details and manage their account.

src/components/account.tsx 中为此创建一个新组件。

🌐 Create a new component for that in src/components/account.tsx.

src/components/account.tsx
1
import { BaseKey, useGetIdentity, useLogout } from '@refinedev/core'
2
3
import { useForm } from '@refinedev/react-hook-form'
4
5
// ...
6
7
interface IUserIdentity {
8
id?: BaseKey
9
username: string
10
name: string
11
}
12
13
export interface IProfile {
14
id?: string
15
username?: string
16
website?: string
17
avatar_url?: string
18
}
19
20
export default function Account() {
21
const { data: userIdentity } = useGetIdentity<IUserIdentity>()
22
23
const { mutate: logOut } = useLogout()
24
25
const {
26
refineCore: { formLoading, query, onFinish },
27
register,
28
control,
29
handleSubmit,
30
} = useForm<IProfile>({
31
refineCoreProps: {
32
resource: 'profiles',
33
action: 'edit',
34
id: userIdentity?.id,
35
redirect: false,
36
onMutationError: (data) => alert(data?.message),
37
},
38
})
39
40
return (
41
<div className="container" style={{ padding: '50px 0 100px 0' }}>
42
<form onSubmit={handleSubmit(onFinish)} className="form-widget">
43
44
{/* ... */}
45
46
<label htmlFor="email">Email</label>
47
<input id="email" name="email" type="text" value={userIdentity?.name} disabled />
48
</div>
49
<div>
50
<label htmlFor="username">Name</label>
51
<input id="username" type="text" {...register('username')} />
52
</div>
53
<div>
54
<label htmlFor="website">Website</label>
55
<input id="website" type="url" {...register('website')} />
56
</div>
57
58
<div>
59
<button className="button block primary" type="submit" disabled={formLoading}>
60
{formLoading ? 'Loading ...' : 'Update'}
61
</button>
62
</div>
63
64
<div>
65
<button className="button block" type="button" onClick={() => logOut()}>
66
Sign Out
67
</button>
68
</div>
69
</form>
70
</div>
71
)
72
}
View source

这使用了三个 Refine 钩子,分别是 useGetIdentity()useLogOut()useForm() 钩子。

🌐 This uses three Refine hooks, namely the useGetIdentity(), useLogOut() and useForm() hooks.

useGetIdentity() 是一个认证钩子,用于获取已认证用户的身份。它通过在底层调用 authProvider.getIdentity 方法来获取当前用户。

useLogOut() 也是一个认证钩子。它调用 authProvider.logout 方法来结束会话。

useForm(),相反,是一个数据钩子,它暴露了一系列用于编辑表单的有用对象。例如,可以获取 onFinish 函数,通过 handleSubmit 事件处理器提交表单。它还使用 formLoading 属性来展示已提交表单的状态变化。

useForm() 钩子是建立在 Refine 的 useForm() 核心钩子之上的高级钩子。它完全支持使用 React Hook Form 进行表单状态管理、字段验证和提交。在幕后,它会调用 dataProvider.getOne 方法从 Supabase 的 /profiles 端点获取用户资料数据,并且在调用 onFinish() 时也会调用 dataProvider.update 方法。

🌐 The useForm() hook is a higher-level hook built on top of Refine's useForm() core hook. It fully supports form state management, field validation and submission using React Hook Form. Behind the scenes, it invokes the dataProvider.getOne method to get the user profile data from the Supabase /profiles endpoint and also invokes dataProvider.update method when onFinish() is called.

头像照片 #

🌐 Profile photos

接下来,添加一个让用户上传个人资料照片的方式。Supabase 会为每个项目配置 Storage,用于管理像照片和视频这样的大文件。

🌐 Next, add a way for users to upload a profile photo. Supabase configures every project with Storage for managing large files like photos and videos.

创建一个上传小工具 #

🌐 Create an upload widget

添加一个新组件:

🌐 Add a new component:

创建并编辑 src/components/avatar.tsx

🌐 Create and edit src/components/avatar.tsx:

src/components/avatar.tsx
1
import { useEffect, useState } from 'react'
2
3
import { supabaseClient } from '../utility/supabaseClient'
4
5
type TAvatarProps = {
6
url?: string
7
size: number
8
onUpload: (filePath: string) => void
9
}
10
11
export default function Avatar({ url, size, onUpload }: TAvatarProps) {
12
const [avatarUrl, setAvatarUrl] = useState('')
13
const [uploading, setUploading] = useState(false)
14
15
useEffect(() => {
16
if (url) downloadImage(url)
17
}, [url])
18
19
async function downloadImage(path: string) {
20
try {
21
const { data, error } = await supabaseClient.storage.from('avatars').download(path)
22
if (error) {
23
throw error
24
}
25
const url = URL.createObjectURL(data)
26
setAvatarUrl(url)
27
} catch (error: any) {
28
console.log('Error downloading image: ', error?.message)
29
}
30
}
31
32
async function uploadAvatar(event: React.ChangeEvent<HTMLInputElement>) {
33
try {
34
setUploading(true)
35
36
if (!event.target.files || event.target.files.length === 0) {
37
throw new Error('You must select an image to upload.')
38
}
39
40
const file = event.target.files[0]
41
const fileExt = file.name.split('.').pop()
42
const fileName = `${Math.random()}.${fileExt}`
43
const filePath = `${fileName}`
44
45
const { error: uploadError } = await supabaseClient.storage
46
.from('avatars')
47
.upload(filePath, file)
48
49
if (uploadError) {
50
throw uploadError
51
}
52
onUpload(filePath)
53
} catch (error: any) {
54
alert(error.message)
55
} finally {
56
setUploading(false)
57
}
58
}
59
60
return (
61
<div>
62
{avatarUrl ? (
63
<img
64
src={avatarUrl}
65
alt="Avatar"
66
className="avatar image"
67
style={{ height: size, width: size }}
68
/>
69
) : (
70
<div className="avatar no-image" style={{ height: size, width: size }} />
71
)}
72
<div style={{ width: size }}>
73
<label className="button primary block" htmlFor="single">
74
{uploading ? 'Uploading ...' : 'Upload'}
75
</label>
76
<input
77
style={{
78
visibility: 'hidden',
79
position: 'absolute',
80
}}
81
type="file"
82
id="single"
83
name="avatar_url"
84
accept="image/*"
85
onChange={uploadAvatar}
86
disabled={uploading}
87
/>
88
</div>
89
</div>
90
)
91
}
View source

更新账户组件 #

🌐 Update the Account component

创建了 Avatar 组件后,更新 src/components/account.tsx 来包含它:

🌐 With the Avatar component created, update src/components/account.tsx to include it:

src/components/account.tsx
1
import { BaseKey, useGetIdentity, useLogout } from '@refinedev/core'
2
3
import { useForm } from '@refinedev/react-hook-form'
4
import { Controller } from 'react-hook-form'
5
6
import Avatar from './avatar'
7
8
interface IUserIdentity {
9
id?: BaseKey
10
username: string
11
name: string
12
}
13
14
export interface IProfile {
15
id?: string
16
username?: string
17
website?: string
18
avatar_url?: string
19
}
20
21
export default function Account() {
22
const { data: userIdentity } = useGetIdentity<IUserIdentity>()
23
24
const { mutate: logOut } = useLogout()
25
26
const {
27
refineCore: { formLoading, query, onFinish },
28
register,
29
control,
30
handleSubmit,
31
} = useForm<IProfile>({
32
refineCoreProps: {
33
resource: 'profiles',
34
action: 'edit',
35
id: userIdentity?.id,
36
redirect: false,
37
onMutationError: (data) => alert(data?.message),
38
},
39
})
40
41
return (
42
<div className="container" style={{ padding: '50px 0 100px 0' }}>
43
<form onSubmit={handleSubmit(onFinish)} className="form-widget">
44
<Controller
45
control={control}
46
name="avatar_url"
47
render={({ field }) => {
48
return (
49
<Avatar
50
url={field.value}
51
size={150}
52
onUpload={(filePath) => {
53
onFinish({
54
...query?.data?.data,
55
avatar_url: filePath,
56
onMutationError: (data: { message: string }) => alert(data?.message),
57
})
58
field.onChange({
59
target: {
60
value: filePath,
61
},
62
})
63
}}
64
/>
65
)
66
}}
67
/>
68
<div>
69
<label htmlFor="email">Email</label>
70
<input id="email" name="email" type="text" value={userIdentity?.name} disabled />
71
</div>
72
<div>
73
<label htmlFor="username">Name</label>
74
<input id="username" type="text" {...register('username')} />
75
</div>
76
<div>
77
<label htmlFor="website">Website</label>
78
<input id="website" type="url" {...register('website')} />
79
</div>
80
81
<div>
82
<button className="button block primary" type="submit" disabled={formLoading}>
83
{formLoading ? 'Loading ...' : 'Update'}
84
</button>
85
</div>
86
87
<div>
88
<button className="button block" type="button" onClick={() => logOut()}>
89
Sign Out
90
</button>
91
</div>
92
</form>
93
</div>
94
)
95
}
View source

触发! #

🌐 Launch!

把所有组件都准备好后,定义它们应该渲染的页面路线。

🌐 With all the components in place, define the routes for the pages in which they should be rendered.

/login 添加带有 <Auth /> 组件的路由,以及为 index 路径添加带有 <Account /> 组件的路由。所以,最终的 App.tsx:

🌐 Add the routes for /login with the <Auth /> component and the routes for index path with the <Account /> component. So, the final App.tsx:

src/App.tsx
1
import { Authenticated, Refine } from '@refinedev/core'
2
import { RefineKbar, RefineKbarProvider } from '@refinedev/kbar'
3
import routerProvider, {
4
CatchAllNavigate,
5
DocumentTitleHandler,
6
UnsavedChangesNotifier,
7
} from '@refinedev/react-router'
8
import { BrowserRouter, Outlet, Route, Routes } from 'react-router'
9
10
import { dataProvider, liveProvider } from '@refinedev/supabase'
11
import authProvider from './authProvider'
12
import { supabaseClient } from './utility'
13
14
import Account from './components/account'
15
import Auth from './components/auth'
16
17
import './App.css'
18
19
function App() {
20
return (
21
<BrowserRouter>
22
<RefineKbarProvider>
23
<Refine
24
dataProvider={dataProvider(supabaseClient)}
25
liveProvider={liveProvider(supabaseClient)}
26
authProvider={authProvider}
27
routerProvider={routerProvider}
28
options={{
29
syncWithLocation: true,
30
warnWhenUnsavedChanges: true,
31
}}
32
>
33
<Routes>
34
<Route
35
element={
36
<Authenticated
37
key="authenticated-routes"
38
fallback={<CatchAllNavigate to="/login" />}
39
>
40
<Outlet />
41
</Authenticated>
42
}
43
>
44
<Route index element={<Account />} />
45
</Route>
46
<Route element={<Authenticated key="auth-pages" fallback={<Outlet />} />}>
47
<Route path="/login" element={<Auth />} />
48
</Route>
49
</Routes>
50
<RefineKbar />
51
<UnsavedChangesNotifier />
52
<DocumentTitleHandler />
53
</Refine>
54
</RefineKbarProvider>
55
</BrowserRouter>
56
)
57
}
58
59
export default App
View source

通过再次运行服务器来测试应用:

🌐 Test the App by running the server again:

1
npm run dev

然后打开浏览器访问 localhost:5173,你应该能看到完成的应用。

🌐 And then open the browser to localhost:5173 and you should see the completed app.

Supabase Refine

在这个阶段,你已经有一个完全可用的应用了!

🌐 At this stage, you have a fully functional application!