入门

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


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

Supabase User Management example

项目设置#

在开始构建之前,您需要设置数据库和 API。您可以通过在 Supabase 中启动一个新项目,然后在数据库中创建一个“schema”来完成此操作。

创建项目#

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

设置数据库 schema#

现在设置数据库 schema。您可以使用 SQL 编辑器中的“用户管理 Starter”快速入门,或者您可以复制/粘贴下面的 SQL 并运行它。

  1. 转到控制面板中的 SQL 编辑器 页面。
  2. 点击 社区 > 快速入门 选项卡下的 用户管理 Starter
  3. 点击 运行

获取 API 详细信息#

现在您已经创建了一些数据库表,您可以使用自动生成的 API 插入数据。

为此,您需要从 项目的 连接 对话框 获取项目 URL 和密钥。

阅读 API 密钥文档 以全面了解所有密钥类型及其用途。

构建应用#

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

初始化 Ionic Vue 应用#

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

1
npm install -g @ionic/cli
2
ionic start supabase-ionic-vue blank --type vue
3
cd supabase-ionic-vue

然后让我们安装唯一的附加依赖项:supabase-js

1
npm install @supabase/supabase-js

最后,我们希望将环境变量保存在一个 .env 文件中。

我们只需要您之前复制的 API URL 和密钥 即可

1
VITE_SUPABASE_URL=YOUR_SUPABASE_URL
2
VITE_SUPABASE_PUBLISHABLE_KEY=YOUR_SUPABASE_PUBLISHABLE_KEY

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

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

设置登录路由#

让我们设置一个 Vue 组件来管理登录和注册。我们将使用 Magic Links,以便用户无需使用密码即可通过他们的电子邮件登录。

1
<template>
2
<ion-page>
3
<ion-header>
4
<ion-toolbar>
5
<ion-title>Login</ion-title>
6
</ion-toolbar>
7
</ion-header>
8
9
<ion-content>
10
<div class="ion-padding">
11
<h1>Supabase + Ionic Vue</h1>
12
<p>Sign in via magic link with your email below</p>
13
</div>
14
<ion-list inset="true">
15
<form @submit.prevent="handleLogin">
16
<ion-item>
17
<ion-label position="stacked">Email</ion-label>
18
<ion-input v-model="email" name="email" autocomplete type="email"></ion-input>
19
</ion-item>
20
<div class="ion-text-center">
21
<ion-button type="submit" fill="clear">Login</ion-button>
22
</div>
23
</form>
24
</ion-list>
25
<p>{{ email }}</p>
26
</ion-content>
27
</ion-page>
28
</template>
29
30
<script lang="ts">
31
import { supabase } from '../supabase'
32
import {
33
IonContent,
34
IonHeader,
35
IonPage,
36
IonTitle,
37
IonToolbar,
38
IonList,
39
IonItem,
40
IonLabel,
41
IonInput,
42
IonButton,
43
toastController,
44
loadingController,
45
} from '@ionic/vue'
46
import { defineComponent, ref } from 'vue'
47
48
export default defineComponent({
49
name: 'LoginPage',
50
components: {
51
IonContent,
52
IonHeader,
53
IonPage,
54
IonTitle,
55
IonToolbar,
56
IonList,
57
IonItem,
58
IonLabel,
59
IonInput,
60
IonButton,
61
},
62
setup() {
63
const email = ref('')
64
const handleLogin = async () => {
65
const loader = await loadingController.create({})
66
const toast = await toastController.create({ duration: 5000 })
67
68
try {
69
await loader.present()
70
const { error } = await supabase.auth.signInWithOtp({ email: email.value })
71
72
if (error) throw error
73
74
toast.message = 'Check your email for the login link!'
75
await toast.present()
76
} catch (error: any) {
77
toast.message = error.error_description || error.message
78
await toast.present()
79
} finally {
80
await loader.dismiss()
81
}
82
}
83
return { handleLogin, email }
84
},
85
})
86
</script>

账户页面#

用户登录后,我们可以允许他们编辑他们的个人资料详细信息并管理他们的帐户。

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

1
<template>
2
<ion-page>
3
<ion-header>
4
<ion-toolbar>
5
<ion-title>Account</ion-title>
6
</ion-toolbar>
7
</ion-header>
8
9
<ion-content>
10
<form @submit.prevent="updateProfile">
11
<ion-item>
12
<ion-label>
13
<p>Email</p>
14
<p>{{ user?.email }}</p>
15
</ion-label>
16
</ion-item>
17
18
<ion-item>
19
<ion-label position="stacked">Name</ion-label>
20
<ion-input type="text" v-model="profile.username" />
21
</ion-item>
22
23
<ion-item>
24
<ion-label position="stacked">Website</ion-label>
25
<ion-input type="url" v-model="profile.website" />
26
</ion-item>
27
28
<div class="ion-text-center">
29
<ion-button type="submit" fill="clear">Update Profile</ion-button>
30
</div>
31
</form>
32
33
<div class="ion-text-center">
34
<ion-button fill="clear" @click="signOut">Log Out</ion-button>
35
</div>
36
</ion-content>
37
</ion-page>
38
</template>
39
40
<script lang="ts">
41
import {
42
IonPage,
43
IonHeader,
44
IonToolbar,
45
IonTitle,
46
IonContent,
47
IonItem,
48
IonLabel,
49
IonInput,
50
IonButton,
51
toastController,
52
loadingController,
53
} from '@ionic/vue'
54
import { defineComponent, onMounted, ref } from 'vue'
55
import { useRouter } from 'vue-router'
56
import { supabase } from '@/supabase'
57
import type { User } from '@supabase/supabase-js'
58
59
export default defineComponent({
60
name: 'AccountPage',
61
components: {
62
IonPage,
63
IonHeader,
64
IonToolbar,
65
IonTitle,
66
IonContent,
67
IonItem,
68
IonLabel,
69
IonInput,
70
IonButton,
71
},
72
setup() {
73
const router = useRouter()
74
const user = ref<User | null>(null)
75
76
const profile = ref({
77
username: '',
78
website: '',
79
avatar_url: '',
80
})
81
82
const getProfile = async () => {
83
const loader = await loadingController.create()
84
const toast = await toastController.create({ duration: 5000 })
85
await loader.present()
86
87
try {
88
const { data, error, status } = await supabase
89
.from('profiles')
90
.select('username, website, avatar_url')
91
.eq('id', user.value?.id)
92
.single()
93
94
if (error && status !== 406) throw error
95
96
if (data) {
97
profile.value = {
98
username: data.username,
99
website: data.website,
100
avatar_url: data.avatar_url,
101
}
102
}
103
} catch (error: any) {
104
toast.message = error.message
105
await toast.present()
106
} finally {
107
await loader.dismiss()
108
}
109
}
110
111
const updateProfile = async () => {
112
const loader = await loadingController.create()
113
const toast = await toastController.create({ duration: 5000 })
114
await loader.present()
115
116
try {
117
const updates = {
118
id: user.value?.id,
119
...profile.value,
120
updated_at: new Date(),
121
}
122
123
const { error } = await supabase.from('profiles').upsert(updates, {
124
returning: 'minimal',
125
})
126
127
if (error) throw error
128
} catch (error: any) {
129
toast.message = error.message
130
await toast.present()
131
} finally {
132
await loader.dismiss()
133
}
134
}
135
136
const signOut = async () => {
137
const loader = await loadingController.create()
138
const toast = await toastController.create({ duration: 5000 })
139
await loader.present()
140
141
try {
142
const { error } = await supabase.auth.signOut()
143
if (error) throw error
144
router.push('/')
145
} catch (error: any) {
146
toast.message = error.message
147
await toast.present()
148
} finally {
149
await loader.dismiss()
150
}
151
}
152
153
onMounted(async () => {
154
const loader = await loadingController.create()
155
await loader.present()
156
157
const { data } = await supabase.auth.getSession()
158
user.value = data.session?.user ?? null
159
160
if (!user.value) {
161
router.push('/')
162
} else {
163
await getProfile()
164
}
165
166
await loader.dismiss()
167
})
168
169
return {
170
user,
171
profile,
172
updateProfile,
173
signOut,
174
}
175
},
176
})
177
</script>

启动!#

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

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

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

1
ionic serve

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

Supabase Ionic Vue

奖励:个人资料照片#

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

创建一个上传小部件#

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

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

Capacitor 是 Ionic 的一个跨平台原生运行时,它使 Web 应用能够通过应用商店部署并提供对原生设备 API 的访问。

Ionic PWA 元素是一个配套包,它将为没有用户界面的某些浏览器 API 提供自定义的 Ionic UI 进行填充。

安装了这些包后,我们可以更新我们的 main.ts 以包含 Ionic PWA 元素的附加引导调用。

1
import { createApp } from 'vue'
2
import App from './App.vue'
3
import router from './router'
4
5
import { IonicVue } from '@ionic/vue'
6
/* Core CSS required for Ionic components to work properly */
7
import '@ionic/vue/css/ionic.bundle.css'
8
9
/* Theme variables */
10
import './theme/variables.css'
11
12
import { defineCustomElements } from '@ionic/pwa-elements/loader'
13
defineCustomElements(window)
14
const app = createApp(App).use(IonicVue).use(router)
15
16
router.isReady().then(() => {
17
app.mount('#app')
18
})

然后创建一个 AvatarComponent

1
<template>
2
<div class="avatar">
3
<div class="avatar_wrapper" @click="uploadAvatar">
4
<img v-if="avatarUrl" :src="avatarUrl" />
5
<ion-icon v-else name="person" class="no-avatar"></ion-icon>
6
</div>
7
</div>
8
</template>
9
10
<script lang="ts">
11
import { ref, toRefs, watch, defineComponent } from 'vue'
12
import { supabase } from '../supabase'
13
import { Camera, CameraResultType } from '@capacitor/camera'
14
import { IonIcon } from '@ionic/vue'
15
import { person } from 'ionicons/icons'
16
export default defineComponent({
17
name: 'AppAvatar',
18
props: { path: String },
19
emits: ['upload', 'update:path'],
20
components: { IonIcon },
21
setup(prop, { emit }) {
22
const { path } = toRefs(prop)
23
const avatarUrl = ref('')
24
25
const downloadImage = async () => {
26
try {
27
const { data, error } = await supabase.storage.from('avatars').download(path.value)
28
if (error) throw error
29
avatarUrl.value = URL.createObjectURL(data!)
30
} catch (error: any) {
31
console.error('Error downloading image: ', error.message)
32
}
33
}
34
35
const uploadAvatar = async () => {
36
try {
37
const photo = await Camera.getPhoto({
38
resultType: CameraResultType.DataUrl,
39
})
40
if (photo.dataUrl) {
41
const file = await fetch(photo.dataUrl)
42
.then((res) => res.blob())
43
.then((blob) => new File([blob], 'my-file', { type: `image/${photo.format}` }))
44
45
const fileName = `${Math.random()}-${new Date().getTime()}.${photo.format}`
46
const { error: uploadError } = await supabase.storage
47
.from('avatars')
48
.upload(fileName, file)
49
if (uploadError) {
50
throw uploadError
51
}
52
emit('update:path', fileName)
53
emit('upload')
54
}
55
} catch (error) {
56
console.log(error)
57
}
58
}
59
60
watch(path, () => {
61
if (path.value) downloadImage()
62
})
63
64
return { avatarUrl, uploadAvatar, person }
65
},
66
})
67
</script>
68
<style>
69
.avatar {
70
display: block;
71
margin: auto;
72
min-height: 150px;
73
}
74
.avatar .avatar_wrapper {
75
margin: 16px auto 16px;
76
border-radius: 50%;
77
overflow: hidden;
78
height: 150px;
79
aspect-ratio: 1;
80
background: var(--ion-color-step-50);
81
border: thick solid var(--ion-color-step-200);
82
}
83
.avatar .avatar_wrapper:hover {
84
cursor: pointer;
85
}
86
.avatar .avatar_wrapper ion-icon.no-avatar {
87
width: 100%;
88
height: 115%;
89
}
90
.avatar img {
91
display: block;
92
object-fit: cover;
93
width: 100%;
94
height: 100%;
95
}
96
</style>

添加新的小部件#

然后我们可以将小部件添加到 Account 页面

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

此时,您已经拥有一个功能齐全的应用!