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