用Supabase和Ionic Vue建立一个简单的用户管理应用程序的教程

394 阅读5分钟

Supabase和Ionic Vue快速入门指南

这个例子提供了使用Supabase和Ionic Vue建立一个简单的用户管理应用程序(从头开始!)的步骤。

这个例子提供了使用Supabase和Ionic Vue建立一个简单的用户管理应用程序(从头开始!)的步骤。它包括:

  • Supabase数据库:一个用于存储用户数据的Postgres数据库
  • SupabaseAuth:用户可以用魔法链接登录(没有密码,只有电子邮件)。
  • Supabase存储:用户可以上传照片。
  • 行级安全:数据受到保护,个人只能访问自己的数据。
  • 即时API。当你创建你的数据库表时,API将自动生成。

在本指南结束时,你将拥有一个允许用户登录并更新一些基本的个人资料细节的应用程序 如果你想自己动手,让我们开始吧!

GitHub

当你在任何时候被卡住时,请看一下这个 repo

项目设置

在我们开始构建之前,我们要设置我们的数据库和API。这很简单,就是在Supabase中启动一个新项目,然后在数据库中创建一个 "模式"。

创建一个项目

  1. 进入app.supabase.com
  2. 点击 "新项目"。
  3. 输入你的项目细节。
  4. 等待新数据库的启动。

设置数据库模式

现在我们要设置数据库模式。我们可以使用SQL编辑器中的 "User Management Starter "快速入门,或者你可以复制/粘贴下面的SQL并自己运行它。

SQL

-- Create a table for public "profiles"
create table profiles (
  id uuid references auth.users not null,
  updated_at timestamp with time zone,
  username text unique,
  avatar_url text,
  website text,

  primary key (id),
  unique(username),
  constraint username_length check (char_length(username) >= 3)
);

alter table profiles enable row level security;

create policy "Public profiles are viewable by everyone."
  on profiles for select
  using ( true );

create policy "Users can insert their own profile."
  on profiles for insert
  with check ( auth.uid() = id );

create policy "Users can update own profile."
  on profiles for update
  using ( auth.uid() = id );

-- Set up Realtime!
begin;
  drop publication if exists supabase_realtime;
  create publication supabase_realtime;
commit;
alter publication supabase_realtime add table profiles;

-- Set up Storage!
insert into storage.buckets (id, name)
values ('avatars', 'avatars');

create policy "Avatar images are publicly accessible."
  on storage.objects for select
  using ( bucket_id = 'avatars' );

create policy "Anyone can upload an avatar."
  on storage.objects for insert
  with check ( bucket_id = 'avatars' );

用户界面

纯文本

1. Go to the "SQL" section.

2. Click "User Management Starter".

3. Click "Run".

获取API密钥

现在你已经创建了一些数据库表,你已经准备好使用自动生成的API插入数据。我们只需要从API设置中获取URL和anon 密钥。

UI

纯文本

1. Go to the "Settings" section.
2. Click "API" in the sidebar.
3. Find your API URL in this page.
4. Find your "anon" and "service_role" keys on this page.

构建应用程序

让我们开始从头开始构建Ionic Vue应用程序

初始化一个Ionic Vue应用程序

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

Shell

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

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

npm install @supabase/supabase-js

最后,我们要把环境变量保存在.env 。我们所需要的是API URL和你之前复制的anon key。

HTML

.env

VUE_APP_SUPABASE_URL=YOUR_SUPABASE_URL
VUE_APP_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY

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

HTML

src/supabase.ts

import { createClient } from '@supabase/supabase-js';

const supabaseUrl = process.env.VUE_APP_SUPABASE_URL as string;
const supabaseAnonKey = process.env.VUE_APP_SUPABASE_ANON_KEY as string;

export const supabase = createClient(supabaseUrl, supabaseAnonKey);

设置一个登录路线

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

HTML

/src/views/Login.vue

<template>
  <ion-page>
    <ion-header>
      <ion-toolbar>
        <ion-title>Login</ion-title>
      </ion-toolbar>
    </ion-header>

    <ion-content>
      <div class="ion-padding">
        <h1>Supabase + Ionic Vue</h1>
        <p>Sign in via magic link with your email below</p>
      </div>
      <ion-list inset="true">
        <form @submit.prevent="handleLogin">
          <ion-item>
            <ion-label position="stacked">Email</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">Login</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.signIn({ email: email.value });

        if (error) throw error;

        toast.message = 'Check your email for the login link!';
        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

HTML

src/views/Account.vue

<template>
  <ion-page>
    <ion-header>
      <ion-toolbar>
        <ion-title>Account</ion-title>
      </ion-toolbar>
    </ion-header>

    <ion-content>
      <form @submit.prevent="updateProfile">
        <ion-item>
          <ion-label>
            <p>Email</p>
            <p>{{ session?.user?.email }}</p>
          </ion-label>
        </ion-item>

        <ion-item>
          <ion-label position="stacked">Name</ion-label>
          <ion-input
            type="text"
            name="username"
            v-model="profile.username"
          ></ion-input>
        </ion-item>

        <ion-item>
          <ion-label position="stacked">Website</ion-label>
          <ion-input
            type="url"
            name="website"
            v-model="profile.website"
          ></ion-input>
        </ion-item>
        <div class="ion-text-center">
          <ion-button fill="clear" type="submit">Update Profile</ion-button>
        </div>
      </form>

      <div class="ion-text-center">
        <ion-button fill="clear" @click="signOut">Log Out</ion-button>
      </div>
    </ion-content>
  </ion-page>
</template>

<script lang="ts">
import { store } from '@/store';
import { supabase } from '@/supabase';
import {
  IonContent,
  IonHeader,
  IonPage,
  IonTitle,
  IonToolbar,
  toastController,
  loadingController,
  IonInput,
  IonItem,
  IonButton,
  IonLabel,
} from '@ionic/vue';
import { User } from '@supabase/supabase-js';
import { defineComponent, onMounted, ref } from 'vue';
export default defineComponent({
  name: 'AccountPage',
  components: {
    IonContent,
    IonHeader,
    IonPage,
    IonTitle,
    IonToolbar,
    IonInput,
    IonItem,
    IonButton,
    IonLabel,
  },
  setup() {
    const session = ref(supabase.auth.session());
    const profile = ref({
      username: '',
      website: '',
      avatar_url: '',
    });
    const user = store.user as User;
    async function getProfile() {
      const loader = await loadingController.create({});
      const toast = await toastController.create({ duration: 5000 });
      await loader.present();
      try {
        let { 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) {
          console.log(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 });
      try {
        await loader.present();
        const updates = {
          id: user.id,
          ...profile.value,
          updated_at: new Date(),
        };
        //
        let { error } = await supabase.from('profiles').upsert(updates, {
          returning: 'minimal', // Don't return the value after inserting
        });
        //
        if (error) throw error;
      } catch (error: any) {
        toast.message = error.message;
        await toast.present();
      } finally {
        await loader.dismiss();
      }
    };

    async function signOut() {
      const loader = await loadingController.create({});
      const toast = await toastController.create({ duration: 5000 });
      await loader.present();
      try {
        let { error } = await supabase.auth.signOut();
        if (error) throw error;
      } catch (error: any) {
        toast.message = error.message;
        await toast.present();
      } finally {
        await loader.dismiss();
      }
    }

    onMounted(() => {
      getProfile();
    });
    return { signOut, profile, session, updateProfile };
  },
});
</script>

启动!

现在我们已经有了所有的组件,让我们来更新App.vue 和我们的路线。

HTML

src/router.index.ts

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(process.env.BASE_URL),
  routes
})

export default router

HTML

src/App.vue

<template>
  <ion-app>
    <ion-router-outlet />
  </ion-app>
</template>

<script lang="ts">
import { IonApp, IonRouterOutlet, useIonRouter } from '@ionic/vue';
import { defineComponent } from 'vue';

import { store } from './store';
import { supabase } from './supabase';

export default defineComponent({
  name: 'App',
  components: {
    IonApp,
    IonRouterOutlet
  },
  setup(){
    const router = useIonRouter();
    store.user = supabase.auth.user() ?? {};
    supabase.auth.onAuthStateChange((_, session) => {
      store.user = session?.user ?? {}
      if(session?.user) {
        router.replace('/account');
      }
    })
  }
});
</script>

一旦完成,在终端窗口运行这个程序。

Shell

ionic serve

然后打开浏览器到localhost:3000,你应该看到完成的应用程序。

奖金:个人资料照片

每个Supabase项目都配置了用于管理照片和视频等大文件的存储器

创建一个上传小工具

首先,安装两个软件包,以便与用户的相机互动。

外壳

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

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

Ionic PWA元素是一个配套的软件包,它将把某些不提供用户界面的浏览器API与自定义的Ionic UI进行聚填。

安装了这些包后,我们可以更新我们的main.ts ,以包括对Ionic PWA元素的额外引导调用。

HTML

import { createApp } from 'vue'
import App from './App.vue'
import router from './router';

import { IonicVue } from '@ionic/vue';
/* Core CSS required for Ionic components to work properly */
import '@ionic/vue/css/ionic.bundle.css';

/* Theme variables */
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

HTML

src/components/Avatar.vue

<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 downloading image: ', 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
          }`;
          let { 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>

添加新的小部件

然后我们可以将小部件添加到账户页面。

JavaScript

src/views/Account.vue

<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>