快速开始

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


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

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 密钥。

构建应用

让我们从零开始构建 Vue 应用。

初始化 Ionic Vue 应用

使用 Ionic CLI 初始化一个名为 supabase-ionic-vue 的应用:

1
2
3
npm install -g @ionic/cliionic start supabase-ionic-vue blank --type vuecd supabase-ionic-vue

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

1
npm install @supabase/supabase-js

最后将环境变量保存在 .env 文件中。

只需要之前获取的 API URL 和 anon 密钥。

1
2
VITE_SUPABASE_URL=YOUR_SUPABASE_URLVITE_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY

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

1
2
3
4
5
6
import { createClient } from '@supabase/supabase-js';const supabaseUrl = import.meta.env.VITE_SUPABASE_URL as string;const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY as string;export const supabase = createClient(supabaseUrl, supabaseAnonKey);

设置登录路由

让我们创建一个Vue组件来管理登录和注册功能。我们将使用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
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
<template> <ion-page> <ion-header> <ion-toolbar> <ion-title>登录</ion-title> </ion-toolbar> </ion-header> <ion-content> <div class="ion-padding"> <h1>Supabase + Ionic Vue</h1> <p>在下方输入您的邮箱,通过魔法链接登录</p> </div> <ion-list inset="true"> <form @submit.prevent="handleLogin"> <ion-item> <ion-label position="stacked">邮箱</ion-label> <ion-input v-model="email" name="email" autocomplete type="email"></ion-input> </ion-item> <div class="ion-text-center"> <ion-button type="submit" fill="clear">登录</ion-button> </div> </form> </ion-list> <p>{{ email }}</p> </ion-content> </ion-page></template><script lang="ts"> import { supabase } from '../supabase' import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar, IonList, IonItem, IonLabel, IonInput, IonButton, toastController, loadingController, } from '@ionic/vue' import { defineComponent, ref } from 'vue' export default defineComponent({ name: 'LoginPage', components: { IonContent, IonHeader, IonPage, IonTitle, IonToolbar, IonList, IonItem, IonLabel, IonInput, IonButton, }, setup() { const email = ref('') const handleLogin = async () => { const loader = await loadingController.create({}) const toast = await toastController.create({ duration: 5000 }) try { await loader.present() const { error } = await supabase.auth.signInWithOtp({ email: email.value }) if (error) throw error toast.message = '请检查您的邮箱获取登录链接!' await toast.present() } catch (error: any) { toast.message = error.error_description || error.message await toast.present() } finally { await loader.dismiss() } } return { handleLogin, email } }, })</script>

账户页面

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

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

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
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
<template> <ion-page> <ion-header> <ion-toolbar> <ion-title>账户</ion-title> </ion-toolbar> </ion-header> <ion-content> <form @submit.prevent="updateProfile"> <ion-item> <ion-label> <p>邮箱</p> <p>{{ user?.email }}</p> </ion-label> </ion-item> <ion-item> <ion-label position="stacked">姓名</ion-label> <ion-input type="text" v-model="profile.username" /> </ion-item> <ion-item> <ion-label position="stacked">网站</ion-label> <ion-input type="url" v-model="profile.website" /> </ion-item> <div class="ion-text-center"> <ion-button type="submit" fill="clear">更新资料</ion-button> </div> </form> <div class="ion-text-center"> <ion-button fill="clear" @click="signOut">退出登录</ion-button> </div> </ion-content> </ion-page></template><script lang="ts"> import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonItem, IonLabel, IonInput, IonButton, toastController, loadingController, } from '@ionic/vue' import { defineComponent, onMounted, ref } from 'vue' import { useRouter } from 'vue-router' import { supabase } from '@/supabase' import type { User } from '@supabase/supabase-js' export default defineComponent({ name: 'AccountPage', components: { IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonItem, IonLabel, IonInput, IonButton, }, setup() { const router = useRouter() const user = ref<User | null>(null) const profile = ref({ username: '', website: '', avatar_url: '', }) const getProfile = async () => { const loader = await loadingController.create() const toast = await toastController.create({ duration: 5000 }) await loader.present() try { const { data, error, status } = await supabase .from('profiles') .select('username, website, avatar_url') .eq('id', user.value?.id) .single() if (error && status !== 406) throw error if (data) { profile.value = { username: data.username, website: data.website, avatar_url: data.avatar_url, } } } catch (error: any) { toast.message = error.message await toast.present() } finally { await loader.dismiss() } } const updateProfile = async () => { const loader = await loadingController.create() const toast = await toastController.create({ duration: 5000 }) await loader.present() try { const updates = { id: user.value?.id, ...profile.value, updated_at: new Date(), } const { error } = await supabase.from('profiles').upsert(updates, { returning: 'minimal', }) if (error) throw error } catch (error: any) { toast.message = error.message await toast.present() } finally { await loader.dismiss() } } const signOut = async () => { const loader = await loadingController.create() const toast = await toastController.create({ duration: 5000 }) await loader.present() try { const { error } = await supabase.auth.signOut() if (error) throw error router.push('/') } catch (error: any) { toast.message = error.message await toast.present() } finally { await loader.dismiss() } } onMounted(async () => { const loader = await loadingController.create() await loader.present() const { data } = await supabase.auth.getSession() user.value = data.session?.user ?? null if (!user.value) { router.push('/') } else { await getProfile() } await loader.dismiss() }) return { user, profile, updateProfile, signOut, } }, })</script>

启动应用!

现在我们已经准备好了所有组件,让我们更新 App.vue 和路由配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { createRouter, createWebHistory } from '@ionic/vue-router'import { RouteRecordRaw } from 'vue-router'import LoginPage from '../views/Login.vue'import AccountPage from '../views/Account.vue'const routes: Array<RouteRecordRaw> = [ { path: '/', name: 'Login', component: LoginPage, }, { path: '/account', name: 'Account', component: AccountPage, },]const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes,})export default router

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

1
ionic serve

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

Supabase Ionic Vue

附加功能:个人资料照片

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

创建上传组件

首先安装两个用于与用户摄像头交互的包。

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { createApp } from 'vue'import App from './App.vue'import router from './router'import { IonicVue } from '@ionic/vue'/* Ionic组件正常工作所需的核心CSS */import '@ionic/vue/css/ionic.bundle.css'/* 主题变量 */import './theme/variables.css'import { defineCustomElements } from '@ionic/pwa-elements/loader'defineCustomElements(window)const app = createApp(App).use(IonicVue).use(router)router.isReady().then(() => { app.mount('#app')})

然后创建一个 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
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
<template> <div class="avatar"> <div class="avatar_wrapper" @click="uploadAvatar"> <img v-if="avatarUrl" :src="avatarUrl" /> <ion-icon v-else name="person" class="no-avatar"></ion-icon> </div> </div></template><script lang="ts"> import { ref, toRefs, watch, defineComponent } from 'vue' import { supabase } from '../supabase' import { Camera, CameraResultType } from '@capacitor/camera' import { IonIcon } from '@ionic/vue' import { person } from 'ionicons/icons' export default defineComponent({ name: 'AppAvatar', props: { path: String }, emits: ['upload', 'update:path'], components: { IonIcon }, setup(prop, { emit }) { const { path } = toRefs(prop) const avatarUrl = ref('') const downloadImage = async () => { try { const { data, error } = await supabase.storage.from('avatars').download(path.value) if (error) throw error avatarUrl.value = URL.createObjectURL(data!) } catch (error: any) { console.error('下载图像时出错: ', error.message) } } const uploadAvatar = async () => { try { const photo = await Camera.getPhoto({ resultType: CameraResultType.DataUrl, }) if (photo.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 } emit('update:path', fileName) emit('upload') } } catch (error) { console.log(error) } } watch(path, () => { if (path.value) downloadImage() }) return { avatarUrl, uploadAvatar, person } }, })</script><style> .avatar { display: block; margin: auto; min-height: 150px; } .avatar .avatar_wrapper { margin: 16px auto 16px; border-radius: 50%; overflow: hidden; height: 150px; aspect-ratio: 1; background: var(--ion-color-step-50); border: thick solid var(--ion-color-step-200); } .avatar .avatar_wrapper:hover { cursor: pointer; } .avatar .avatar_wrapper ion-icon.no-avatar { width: 100%; height: 115%; } .avatar img { display: block; object-fit: cover; width: 100%; height: 100%; }</style>

添加新组件

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<template> <ion-page> <ion-header> <ion-toolbar> <ion-title>Account</ion-title> </ion-toolbar> </ion-header> <ion-content> <avatar v-model:path="profile.avatar_url" @upload="updateProfile"></avatar>...</template><script lang="ts">import Avatar from '../components/Avatar.vue';export default defineComponent({ name: 'AccountPage', components: { Avatar, .... }</script>

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