使用 Ionic Vue 构建用户管理应用
本教程演示如何构建一个基本的用户管理应用。该应用对用户进行身份验证和识别,将他们的个人资料信息存储在数据库中,并允许用户登录、更新他们的个人资料详细信息以及上传个人资料照片。该应用使用
- Supabase 数据库 - 一个用于存储用户数据的 Postgres 数据库,以及 行级别安全,以保护数据并确保用户只能访问他们自己的信息。
- Supabase Auth - 允许用户注册和登录。
- Supabase Storage - 允许用户上传个人资料照片。

如果在阅读本指南时遇到问题,请参考 GitHub 上的完整示例。
项目设置#
在开始构建之前,您需要设置数据库和 API。您可以通过在 Supabase 中启动一个新项目,然后在数据库中创建一个“schema”来完成此操作。
创建项目#
- 在 Supabase 控制面板中创建一个新项目。
- 输入您的项目详细信息。
- 等待新的数据库启动。
设置数据库 schema#
现在设置数据库 schema。您可以使用 SQL 编辑器中的“用户管理 Starter”快速入门,或者您可以复制/粘贴下面的 SQL 并运行它。
获取 API 详细信息#
现在您已经创建了一些数据库表,您可以使用自动生成的 API 插入数据。
为此,您需要从 项目的 连接 对话框 获取项目 URL 和密钥。
API 密钥的更改
Supabase 正在更改密钥的工作方式,以提高项目安全性和开发人员体验。您可以 阅读完整的公告,但在过渡期间,您可以使用当前的 anon 和 service_role 密钥以及新的可发布密钥,格式为 sb_publishable_xxx,它将取代旧的密钥。
在大多数情况下,您可以从 项目的 连接 对话框 获取正确的密钥,但如果您需要特定的密钥,则可以在 项目设置页面的 API 密钥部分 找到所有密钥
- 对于旧版密钥,从 旧版 API 密钥 选项卡中复制
anon密钥用于客户端操作,并复制service_role密钥用于服务器端操作。 - 对于新密钥,打开 API 密钥 选项卡,如果您还没有可发布密钥,请单击 创建新的 API 密钥,并复制 可发布密钥 部分中的值。
阅读 API 密钥文档 以全面了解所有密钥类型及其用途。
构建应用#
让我们从头开始构建 Vue 应用。
初始化 Ionic Vue 应用#
我们可以使用 Ionic CLI 初始化一个名为 supabase-ionic-vue 的应用
1npm install -g @ionic/cli2ionic start supabase-ionic-vue blank --type vue3cd supabase-ionic-vue然后让我们安装唯一的附加依赖项:supabase-js
1npm install @supabase/supabase-js最后,我们希望将环境变量保存在一个 .env 文件中。
我们只需要您之前复制的 API URL 和密钥 即可。
1VITE_SUPABASE_URL=YOUR_SUPABASE_URL2VITE_SUPABASE_PUBLISHABLE_KEY=YOUR_SUPABASE_PUBLISHABLE_KEY现在我们已经准备好了 API 凭据,让我们创建一个辅助文件来初始化 Supabase 客户端。这些变量将在浏览器上暴露,这完全没问题,因为我们在数据库上启用了 行级别安全。
1import { createClient } from '@supabase/supabase-js';23const supabaseUrl = import.meta.env.VITE_SUPABASE_URL as string;4const supabasePublishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY as string;56export 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>89 <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>2930<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'4748 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 })6768 try {69 await loader.present()70 const { error } = await supabase.auth.signInWithOtp({ email: email.value })7172 if (error) throw error7374 toast.message = 'Check your email for the login link!'75 await toast.present()76 } catch (error: any) {77 toast.message = error.error_description || error.message78 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>89 <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>1718 <ion-item>19 <ion-label position="stacked">Name</ion-label>20 <ion-input type="text" v-model="profile.username" />21 </ion-item>2223 <ion-item>24 <ion-label position="stacked">Website</ion-label>25 <ion-input type="url" v-model="profile.website" />26 </ion-item>2728 <div class="ion-text-center">29 <ion-button type="submit" fill="clear">Update Profile</ion-button>30 </div>31 </form>3233 <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>3940<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'5859 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)7576 const profile = ref({77 username: '',78 website: '',79 avatar_url: '',80 })8182 const getProfile = async () => {83 const loader = await loadingController.create()84 const toast = await toastController.create({ duration: 5000 })85 await loader.present()8687 try {88 const { data, error, status } = await supabase89 .from('profiles')90 .select('username, website, avatar_url')91 .eq('id', user.value?.id)92 .single()9394 if (error && status !== 406) throw error9596 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.message105 await toast.present()106 } finally {107 await loader.dismiss()108 }109 }110111 const updateProfile = async () => {112 const loader = await loadingController.create()113 const toast = await toastController.create({ duration: 5000 })114 await loader.present()115116 try {117 const updates = {118 id: user.value?.id,119 ...profile.value,120 updated_at: new Date(),121 }122123 const { error } = await supabase.from('profiles').upsert(updates, {124 returning: 'minimal',125 })126127 if (error) throw error128 } catch (error: any) {129 toast.message = error.message130 await toast.present()131 } finally {132 await loader.dismiss()133 }134 }135136 const signOut = async () => {137 const loader = await loadingController.create()138 const toast = await toastController.create({ duration: 5000 })139 await loader.present()140141 try {142 const { error } = await supabase.auth.signOut()143 if (error) throw error144 router.push('/')145 } catch (error: any) {146 toast.message = error.message147 await toast.present()148 } finally {149 await loader.dismiss()150 }151 }152153 onMounted(async () => {154 const loader = await loadingController.create()155 await loader.present()156157 const { data } = await supabase.auth.getSession()158 user.value = data.session?.user ?? null159160 if (!user.value) {161 router.push('/')162 } else {163 await getProfile()164 }165166 await loader.dismiss()167 })168169 return {170 user,171 profile,172 updateProfile,173 signOut,174 }175 },176 })177</script>启动!#
现在我们已经准备好了所有组件,让我们更新 App.vue 和我们的路由
1import { createRouter, createWebHistory } from '@ionic/vue-router'2import { RouteRecordRaw } from 'vue-router'3import LoginPage from '../views/Login.vue'4import AccountPage from '../views/Account.vue'5const 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]1718const router = createRouter({19 history: createWebHistory(import.meta.env.BASE_URL),20 routes,21})2223export default router完成此操作后,在终端窗口中运行以下命令
1ionic serve然后在浏览器中打开 localhost:3000,您应该会看到完成的应用。

奖励:个人资料照片#
每个 Supabase 项目都配置了 存储,用于管理大型文件,如照片和视频。
创建一个上传小部件#
首先安装两个包以与用户的摄像头交互。
1npm install @ionic/pwa-elements @capacitor/cameraCapacitor 是 Ionic 的一个跨平台原生运行时,它使 Web 应用能够通过应用商店部署并提供对原生设备 API 的访问。
Ionic PWA 元素是一个配套包,它将为没有用户界面的某些浏览器 API 提供自定义的 Ionic UI 进行填充。
安装了这些包后,我们可以更新我们的 main.ts 以包含 Ionic PWA 元素的附加引导调用。
1import { createApp } from 'vue'2import App from './App.vue'3import router from './router'45import { IonicVue } from '@ionic/vue'6/* Core CSS required for Ionic components to work properly */7import '@ionic/vue/css/ionic.bundle.css'89/* Theme variables */10import './theme/variables.css'1112import { defineCustomElements } from '@ionic/pwa-elements/loader'13defineCustomElements(window)14const app = createApp(App).use(IonicVue).use(router)1516router.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>910<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('')2425 const downloadImage = async () => {26 try {27 const { data, error } = await supabase.storage.from('avatars').download(path.value)28 if (error) throw error29 avatarUrl.value = URL.createObjectURL(data!)30 } catch (error: any) {31 console.error('Error downloading image: ', error.message)32 }33 }3435 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}` }))4445 const fileName = `${Math.random()}-${new Date().getTime()}.${photo.format}`46 const { error: uploadError } = await supabase.storage47 .from('avatars')48 .upload(fileName, file)49 if (uploadError) {50 throw uploadError51 }52 emit('update:path', fileName)53 emit('upload')54 }55 } catch (error) {56 console.log(error)57 }58 }5960 watch(path, () => {61 if (path.value) downloadImage()62 })6364 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>89 <ion-content>10 <avatar v-model:path="profile.avatar_url" @upload="updateProfile"></avatar>11...12</template>13<script lang="ts">14import Avatar from '../components/Avatar.vue';15export default defineComponent({16 name: 'AccountPage',17 components: {18 Avatar,19 ....20 }2122</script>此时,您已经拥有一个功能齐全的应用!