快速开始

使用 Ionic React 构建用户管理应用


本教程展示如何构建一个基本的用户管理应用程序。该应用程序对用户进行身份验证和识别,将其个人资料信息存储在数据库中,并允许用户登录、更新个人资料详细信息以及上传个人资料照片。该应用程序使用:

Supabase 用户管理示例

项目设置

在开始构建之前,我们需要设置数据库和 API。这就像在 Supabase 中创建一个新项目,然后在数据库中创建一个 “模式” 一样简单。

创建项目

  1. 在 Supabase 仪表板中创建一个新项目
  2. 输入项目详细信息。
  3. 等待新数据库启动。

设置数据库模式

现在我们要设置数据库模式。我们可以在 SQL 编辑器中使用 “用户管理入门” 快速启动模板,或者您也可以直接复制/粘贴下面的 SQL 并自行运行。

  1. 转到仪表板中的SQL 编辑器页面。
  2. 点击 用户管理入门
  3. 点击 运行
1
2
3
4
supabase link --project-ref <project-id># 您可以从项目的仪表板 URL 获取 <project-id>:https://supabase.com/dashboard/project/<project-id>supabase db pull

获取 API 密钥

既然您已经创建了一些数据库表,就可以使用自动生成的 API 插入数据了。 我们只需要从 API 设置中获取项目 URL 和 anon 密钥。

  1. 前往仪表板中的 API 设置 页面。
  2. 在该页面找到您的项目 URLanonservice_role 密钥。

构建应用

让我们从零开始构建这个React应用。

初始化Ionic React应用

我们可以使用 Ionic CLI 来初始化一个名为 supabase-ionic-react 的应用:

1
2
3
npm install -g @ionic/cliionic start supabase-ionic-react blank --type reactcd supabase-ionic-react

然后安装唯一的额外依赖:supabase-js

1
npm install @supabase/supabase-js

最后我们需要将环境变量保存在.env文件中。 只需要之前获取的API URL和anon密钥。

1
2
REACT_APP_SUPABASE_URL=YOUR_SUPABASE_URLREACT_APP_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY

现在我们已经配置好了API凭证,让我们创建一个辅助文件来初始化Supabase客户端。这些变量会在浏览器端暴露,这完全没问题,因为我们的数据库已经启用了行级安全

1
2
3
4
5
6
import { createClient } from '@supabase/supabase-js'const supabaseUrl = process.env.REACT_APP_SUPABASE_URLconst supabaseAnonKey = process.env.REACT_APP_SUPABASE_ANON_KEYexport const supabase = createClient(supabaseUrl, supabaseAnonKey)

设置登录路由

让我们创建一个React组件来管理登录和注册功能。我们将使用Magic Links(魔法链接)方式,让用户无需密码即可通过邮箱登录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import { useState } from 'react';import { IonButton, IonContent, IonHeader, IonInput, IonItem, IonLabel, IonList, IonPage, IonTitle, IonToolbar, useIonToast, useIonLoading,} from '@ionic/react';import { supabase } from '../supabaseClient';export function LoginPage() { const [email, setEmail] = useState(''); const [showLoading, hideLoading] = useIonLoading(); const [showToast ] = useIonToast(); const handleLogin = async (e: React.FormEvent<HTMLFormElement>) => { console.log() e.preventDefault(); await showLoading(); try { await supabase.auth.signIn({ email }); await showToast({ message: '请检查您的邮箱获取登录链接!' }); } catch (e: any) { await showToast({ message: e.error_description || e.message , duration: 5000}); } finally { await hideLoading(); } }; return ( <IonPage> <IonHeader> <IonToolbar> <IonTitle>登录</IonTitle> </IonToolbar> </IonHeader> <IonContent> <div className="ion-padding"> <h1>Supabase + Ionic React</h1> <p>通过下方邮箱输入框使用魔法链接登录</p> </div> <IonList inset={true}> <form onSubmit={handleLogin}> <IonItem> <IonLabel position="stacked">邮箱</IonLabel> <IonInput value={email} name="email" onIonChange={(e) => setEmail(e.detail.value ?? '')} type="email" ></IonInput> </IonItem> <div className="ion-text-center"> <IonButton type="submit" fill="clear"> 登录 </IonButton> </div> </form> </IonList> </IonContent> </IonPage> );}

账户页面

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

让我们创建一个名为Account.tsx的新组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
import { IonButton, IonContent, IonHeader, IonInput, IonItem, IonLabel, IonPage, IonTitle, IonToolbar, useIonLoading, useIonToast, useIonRouter} from '@ionic/react';import { useEffect, useState } from 'react';import { supabase } from '../supabaseClient';export function AccountPage() { const [showLoading, hideLoading] = useIonLoading(); const [showToast] = useIonToast(); const [session] = useState(() => supabase.auth.session()); const router = useIonRouter(); const [profile, setProfile] = useState({ username: '', website: '', avatar_url: '', }); useEffect(() => { getProfile(); }, [session]); const getProfile = async () => { console.log('get'); await showLoading(); try { const user = supabase.auth.user(); const { data, error, status } = await supabase .from('profiles') .select(`username, website, avatar_url`) .eq('id', user!.id) .single(); if (error && status !== 406) { throw error; } if (data) { setProfile({ username: data.username, website: data.website, avatar_url: data.avatar_url, }); } } catch (error: any) { showToast({ message: error.message, duration: 5000 }); } finally { await hideLoading(); } }; const signOut = async () => { await supabase.auth.signOut(); router.push('/', 'forward', 'replace'); } const updateProfile = async (e?: any, avatar_url: string = '') => { e?.preventDefault(); console.log('update '); await showLoading(); try { const user = supabase.auth.user(); const updates = { id: user!.id, ...profile, avatar_url: avatar_url, updated_at: new Date(), }; const { error } = await supabase.from('profiles').upsert(updates, { returning: 'minimal', // 插入后不返回值 }); if (error) { throw error; } } catch (error: any) { showToast({ message: error.message, duration: 5000 }); } finally { await hideLoading(); } }; return ( <IonPage> <IonHeader> <IonToolbar> <IonTitle>账户</IonTitle> </IonToolbar> </IonHeader> <IonContent> <form onSubmit={updateProfile}> <IonItem> <IonLabel> <p>邮箱</p> <p>{session?.user?.email}</p> </IonLabel> </IonItem> <IonItem> <IonLabel position="stacked">姓名</IonLabel> <IonInput type="text" name="username" value={profile.username} onIonChange={(e) => setProfile({ ...profile, username: e.detail.value ?? '' }) } ></IonInput> </IonItem> <IonItem> <IonLabel position="stacked">网站</IonLabel> <IonInput type="url" name="website" value={profile.website} onIonChange={(e) => setProfile({ ...profile, website: e.detail.value ?? '' }) } ></IonInput> </IonItem> <div className="ion-text-center"> <IonButton fill="clear" type="submit"> 更新资料 </IonButton> </div> </form> <div className="ion-text-center"> <IonButton fill="clear" onClick={signOut}> 退出登录 </IonButton> </div> </IonContent> </IonPage> );}

启动应用!

现在我们已经准备好了所有组件,让我们更新 App.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import { Redirect, Route } from 'react-router-dom'import { IonApp, IonRouterOutlet, setupIonicReact } from '@ionic/react'import { IonReactRouter } from '@ionic/react-router'import { supabase } from './supabaseClient'import '@ionic/react/css/ionic.bundle.css'/* 主题变量 */import './theme/variables.css'import { LoginPage } from './pages/Login'import { AccountPage } from './pages/Account'import { useEffect, useState } from 'react'import { Session } from '@supabase/supabase-js'setupIonicReact()const App: React.FC = () => { const [session, setSession] = useState < Session > null useEffect(() => { setSession(supabase.auth.session()) supabase.auth.onAuthStateChange((_event, session) => { setSession(session) }) }, []) return ( <IonApp> <IonReactRouter> <IonRouterOutlet> <Route exact path="/" render={() => { return session ? <Redirect to="/account" /> : <LoginPage /> }} /> <Route exact path="/account"> <AccountPage /> </Route> </IonRouterOutlet> </IonReactRouter> </IonApp> )}export default App

完成后,在终端窗口中运行以下命令:

1
ionic serve

然后在浏览器中打开 localhost:3000,您应该能看到完整的应用。

Supabase Ionic React

额外功能:个人资料照片

每个 Supabase 项目都配置了存储功能,用于管理照片和视频等大型文件。

创建上传组件

首先安装两个包以便与用户相机交互。

1
npm install @ionic/pwa-elements @capacitor/camera

Capacitor 是 Ionic 提供的跨平台原生运行时,允许网页应用通过应用商店部署并提供原生设备 API 访问。

Ionic PWA elements 是一个配套包,将为某些不提供用户界面的浏览器 API 提供自定义 Ionic UI 的 polyfill。

安装这些包后,我们可以更新 index.tsx 文件以包含对 Ionic PWA Elements 的额外初始化调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import React from 'react'import ReactDOM from 'react-dom'import App from './App'import * as serviceWorkerRegistration from './serviceWorkerRegistration'import reportWebVitals from './reportWebVitals'import { defineCustomElements } from '@ionic/pwa-elements/loader'defineCustomElements(window)ReactDOM.render( <React.StrictMode> <App /> </React.StrictMode>, document.getElementById('root'))serviceWorkerRegistration.unregister()reportWebVitals()

然后创建一个 AvatarComponent 组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
import { IonIcon } from '@ionic/react';import { person } from 'ionicons/icons';import { Camera, CameraResultType } from '@capacitor/camera';import { useEffect, useState } from 'react';import { supabase } from '../supabaseClient';import './Avatar.css'export function Avatar({ url, onUpload,}: { url: string; onUpload: (e: any, file: string) => Promise<void>;}) { const [avatarUrl, setAvatarUrl] = useState<string | undefined>(); useEffect(() => { if (url) { downloadImage(url); } }, [url]); const uploadAvatar = async () => { try { const photo = await Camera.getPhoto({ resultType: CameraResultType.DataUrl, }); const file = await fetch(photo.dataUrl!) .then((res) => res.blob()) .then( (blob) => new File([blob], 'my-file', { type: `image/${photo.format}` }) ); const fileName = `${Math.random()}-${new Date().getTime()}.${ photo.format }`; const { error: uploadError } = await supabase.storage .from('avatars') .upload(fileName, file); if (uploadError) { throw uploadError; } onUpload(null, fileName); } catch (error) { console.log(error); } }; const downloadImage = async (path: string) => { try { const { data, error } = await supabase.storage .from('avatars') .download(path); if (error) { throw error; } const url = URL.createObjectURL(data!); setAvatarUrl(url); } catch (error: any) { console.log('Error downloading image: ', error.message); } }; return ( <div className="avatar"> <div className="avatar_wrapper" onClick={uploadAvatar}> {avatarUrl ? ( <img src={avatarUrl} /> ) : ( <IonIcon icon={person} className="no-avatar" /> )} </div> </div> );}

添加新组件

然后我们可以将这个组件添加到账户页面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 导入新组件import { Avatar } from '../components/Avatar';// ...return ( <IonPage> <IonHeader> <IonToolbar> <IonTitle>Account</IonTitle> </IonToolbar> </IonHeader> <IonContent> <Avatar url={profile.avatar_url} onUpload={updateProfile}></Avatar>

至此,您已经拥有了一个功能完整的应用程序!