使用 Ionic Vue 构建用户管理应用
本教程展示如何构建一个基本的用户管理应用程序。该应用程序对用户进行身份验证和识别,将其个人资料信息存储在数据库中,并允许用户登录、更新个人资料详细信息以及上传个人资料照片。该应用程序使用:
- Supabase数据库 - 一个Postgres数据库,用于存储用户数据,并通过行级安全性来保护数据,确保用户只能访问自己的信息。
- Supabase身份验证 - 允许用户注册和登录。
- Supabase存储 - 用户可以上传个人资料照片。
如果在学习本指南时遇到问题,请参考 GitHub 上的完整示例。
项目设置
在开始构建之前,我们需要设置数据库和 API。这就像在 Supabase 中创建一个新项目,然后在数据库中创建一个 “模式” 一样简单。
创建项目
- 在 Supabase 仪表板中创建一个新项目。
- 输入项目详细信息。
- 等待新数据库启动。
设置数据库模式
现在我们要设置数据库模式。我们可以在 SQL 编辑器中使用 “用户管理入门” 快速启动模板,或者您也可以直接复制/粘贴下面的 SQL 并自行运行。
获取 API 密钥
既然您已经创建了一些数据库表,就可以使用自动生成的 API 插入数据了。
我们只需要从 API 设置中获取项目 URL 和 anon
密钥。
- 前往仪表板中的 API 设置 页面。
- 在该页面找到您的项目
URL
、anon
和service_role
密钥。
构建应用
让我们从零开始构建 Vue 应用。
初始化 Ionic Vue 应用
使用 Ionic CLI 初始化一个名为 supabase-ionic-vue
的应用:
123npm install -g @ionic/cliionic start supabase-ionic-vue blank --type vuecd supabase-ionic-vue
然后安装唯一的额外依赖:supabase-js
1npm install @supabase/supabase-js
最后将环境变量保存在 .env
文件中。
只需要之前获取的 API URL 和 anon
密钥。
12VITE_SUPABASE_URL=YOUR_SUPABASE_URLVITE_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
现在我们已经配置好 API 凭证,让我们创建一个辅助文件来初始化 Supabase 客户端。这些变量会在浏览器端暴露,这完全没问题,因为我们的数据库已启用行级安全。
123456import { 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(魔法链接)方式,让用户无需密码即可通过电子邮件登录。
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586<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
的新组件。
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177<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
和路由配置:
1234567891011121314151617181920212223import { 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
完成后,在终端窗口中运行以下命令:
1ionic serve
然后在浏览器中打开 localhost:3000,您应该能看到完整的应用。
附加功能:个人资料照片
每个 Supabase 项目都配置了存储服务,用于管理照片和视频等大型文件。
创建上传组件
首先安装两个用于与用户摄像头交互的包。
1npm install @ionic/pwa-elements @capacitor/camera
Capacitor 是 Ionic 提供的跨平台原生运行时,允许网页应用通过应用商店部署并提供原生设备 API 访问。
Ionic PWA elements 是一个配套包,将为没有用户界面的浏览器 API 提供自定义 Ionic UI 的 polyfill 实现。
安装这些包后,我们可以更新 main.ts
文件以包含对 Ionic PWA Elements 的额外初始化调用。
123456789101112131415161718import { 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
头像组件。
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596<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>
添加新组件
然后我们可以将这个组件添加到账户页面:
12345678910111213141516171819202122<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>
至此,您已经拥有一个功能完整的应用程序了!