基于 Supabase 与 Vue 3 构建用户管理应用:Magic Link 登录、数据库与头像存储全流程实战
2026/9/7 22:59:26 网站建设 项目流程

基于 Supabase 与 Vue 3 构建用户管理应用:Magic Link 登录、数据库与头像存储全流程实战

【免费下载链接】supabaseThe Postgres development platform. Supabase gives you a dedicated Postgres database to build your web, mobile, and AI applications.项目地址: https://gitcode.com/GitHub_Trending/supa/supabase

本文以开源仓库 examples/user-management/vue3-user-management 为例,系统讲解如何用 Vue 3(Composition API +<script setup>)结合 Supabase 从零搭建一个带"邮箱 Magic Link 免密登录、用户资料读写、头像上传展示"的完整用户管理应用。读完本文,你将掌握 Supabase Auth / Database / Storage 三件套与 Vue 3 的整合套路,并理解每一段 SQL 与每一段前端代码背后的设计意图,可直接照搬到自己的 Vite + Vue 3 项目中。

示例项目概览与技术栈

这是一个小而完整的 Vue 3 单页应用示例,围绕"用户中心"这一常见业务场景,串联起 Supabase 的三项核心能力:

  • Supabase Auth:用邮箱 + 一次性密码(Magic Link)完成免密登录;
  • Supabase Database:存储与检索用户profiles资料(用户名、个人网站、头像地址);
  • Supabase Storage:把头像图片上传到avatarsbucket,并可公开下载展示。

项目采用的依赖与脚本可直接从 package.json 看到:

{ "name": "vue3-user-management", "type": "module", "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" }, "dependencies": { "@supabase/supabase-js": "^2", "vue": "^3.5.30" }, "devDependencies": { "@vitejs/plugin-vue": "^6.0.4", "vite": "^7.3.1" } }

从依赖版本可以看到这是 Vite 7 + Vue 3.5 + Supabase JS v2 的现代组合;构建工具配置非常精简,vite.config.js 仅注册了@vitejs/plugin-vue一个插件,入口 HTML 通过<script type="module" src="/src/main.js">挂载应用。

目录结构划分清晰,各文件职责单一:

examples/user-management/vue3-user-management/ ├── src/ │ ├── main.js # 应用入口,mount 到 #app │ ├── App.vue # 顶层路由分流:已登录显示 Account,未登录显示 Auth │ ├── supabase.js # 创建并导出 supabase 客户端单例 │ ├── store.js # 全局响应式 store(存放 user) │ ├── style.css # 深色主题与栅格/表单样式 │ └── components/ │ ├── Auth.vue # 邮箱输入 + 发送 Magic Link │ ├── Account.vue # 资料表单的读取/更新与退出登录 │ └── Avatar.vue # 头像上传与下载展示(支持 v-model:path) ├── index.html ├── vite.config.js └── package.json

一、前置准备:创建 Supabase 项目并注入环境变量

在运行应用之前,需要先创建一个 Supabase 项目,拿到 URL 和密钥后写入.env文件。示例约定使用 Vite 的import.meta.env机制读取两个环境变量,见 src/supabase.js:

import { createClient } from '@supabase/supabase-js' const supabaseUrl = import.meta.env.VITE_SUPABASE_URL const supabasePublishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY export const supabase = createClient(supabaseUrl, supabasePublishableKey)

因此需要在项目根目录创建.env文件(该文件通常应被加入.gitignore,避免密钥泄露):

VITE_SUPABASE_URL=你的项目地址(形如 https://xxxx.supabase.co) VITE_SUPABASE_PUBLISHABLE_KEY=你的 Publishable Key

两点需要注意:

  • 只有以VITE_前缀开头的变量才会被 Vite 暴露给客户端代码;
  • 这里使用的是Publishable Key(发布用公钥)而非 Service Role Key——Service Role Key 可以绕过行级安全策略(RLS),绝不能出现在浏览器端。示例代码全程使用可安全暴露于前端的密钥,数据库层面的访问控制完全交给 RLS 策略来完成,这正是后续 SQL 中"建表即开 RLS"的原因。

环境变量在客户端为何可用

从 Vite 的机制看,import.meta.env.VITE_*是在构建期被静态替换为字面量的,因此VITE_SUPABASE_URLVITE_SUPABASE_PUBLISHABLE_KEY写错或缺失时,构建不会直接报错,但运行时createClient会收到undefined,导致所有请求失败。实际运行时如果点击登录无反应或请求报错,第一步应先检查.env中两个变量是否与项目仪表盘的设置页一致。

二、数据库 Schema:profiles 表 + RLS 策略 + Realtime + Storage 全量建表 SQL

README 给出了可直接在 Supabase SQL Editor 中执行的完整初始化脚本。它分为四块,我们逐一拆解。

1. 创建 profiles 用户资料表

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

设计要点:

  • id uuid references auth.users not null:主键直接引用 Supabase Auth 内置的auth.users表,保证"一个登录用户至多一条资料记录",并天然建立外键关联。由于外键存在,若用户被删除,其资料也应被清理(生产环境可进一步添加on delete cascade)。
  • username text unique与表级unique(username)是同一约束的两种写法,作用是确保用户名全局唯一。
  • check (char_length(username) >= 3):数据库层兜底校验用户名至少 3 个字符,即便前端不做校验也无法写入非法值——这与 Account.vue 中前端无用户名长度校验形成呼应:安全边界永远在服务端/数据库

2. 开启行级安全并定义三条访问策略

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 ( (select auth.uid()) = id ); create policy "Users can update own profile." on profiles for update using ( (select auth.uid()) = id );

这是整个示例安全模型的灵魂,需要逐条理解:

  • enable row level security:开启 RLS 后,若没有匹配的策略,普通客户端对表的任何操作都会被拒绝(即使持有合法的公钥);
  • select全公开using (true)表示任何已登录或匿名请求都可以读取所有人的资料,这是"个人主页公开可见"的产品需求;
  • insert只能写自己with check ((select auth.uid()) = id)校验写入行的id必须等于当前登录用户的auth.uid(),防止越权创建他人资料;
  • update只能改自己using ((select auth.uid()) = id)限定只能更新id等于当前用户的行。

这里没有单独定义delete策略,意味着普通用户无法删除任何资料行(默认拒绝),符合"用户不可注销资料"的最小权限原则。auth.uid()是 Supabase 在数据库层提供的内置函数,返回当前 JWT 中的用户 ID,前端无法伪造,因此这套策略是可信的安全边界。

3. 启用 Realtime 发布

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

Supabase Realtime 基于 PostgreSQL 的逻辑复制(publication/subscription)实现。这段脚本先重建supabase_realtime发布,再把profiles表加入发布列表,使该表的变更能够实时推送给订阅的客户端。在本示例中它更多是"为实时能力预留":后续若想让多端在线同步资料更新,无需改表结构,前端直接对profiles建订阅即可生效。

4. 创建 avatars 存储桶与访问策略

-- 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' );
  • 先在storage.buckets元数据表中插入名为avatars的存储桶;
  • 存储对象(storage.objects)同样受 RLS 管辖,因此必须显式授权:允许所有人读取avatars桶中的对象(头像需要公开展示),允许任何人向该桶上传文件。

需要强调的是:当前存储桶策略并未限制上传者身份与文件大小/类型with check (bucket_id = 'avatars')只约束目标桶。在面向真实生产环境时,通常还需要叠加"仅登录用户可上传"(校验auth.uid())、限制对象路径、配合服务端或客户端校验图片类型与体积等更细粒度的策略。原 README 的这段脚本是演示级的最小可用方案,读者应结合自身安全要求扩展。

完整 SQL 原样保存在 examples/user-management/vue3-user-management/README.md,可直接在 Supabase 控制台 SQL Editor 中整段执行。

三、初始化 Supabase 客户端与全局响应式状态

客户端单例(src/supabase.js)

前面已经看过supabase.js的实现:用createClient创建唯一客户端实例并导出。整个应用(Auth 登录、Account 读写、Avatar 上传)都复用这一个实例,这是@supabase/supabase-jsv2 的标准用法。

全局状态(src/store.js)

import { reactive } from 'vue' export const store = reactive({ user: {}, })

示例用 Vue 3 自带的reactive维护一个极简全局 store 占位用户状态,没有引入 Pinia 等额外状态库。需要说明的是:本示例实际上并没有深度使用store.user——真正的"当前用户是谁"判断,是通过 App.vue 中读取 JWT claims 完成的(见下节),登录态由 Supabase Auth 自身维护。store的存在更多是演示"如何预留一个全局可共享的响应式状态容器"。

四、登录分流与登录态判断:JWT Claims 驱动视图切换

应用入口 src/main.js 只有四行:引入样式、引入根组件、createApp(App).mount('#app')

视图层的核心分流逻辑在 src/App.vue:

<script setup> import { onMounted, ref } from 'vue' import Account from './components/Account.vue' import Auth from './components/Auth.vue' import { supabase } from './supabase' const claims = ref() onMounted(() => { supabase.auth.getClaims().then(({ data }) => { claims.value = data.claims }) supabase.auth.onAuthStateChange(async () => { const { data } = await supabase.auth.getClaims() claims.value = data.claims }) }) </script> <template> <div class="container" style="padding: 50px 0 100px 0"> <Account v-if="claims" :claims="claims" /> <Auth v-else /> </div> </template>

这是本示例最有借鉴价值的模式之一,值得深挖三点:

1.claims作为登录态判据。组件挂载时调用supabase.auth.getClaims()读取当前会话 JWT 的 claims;如果claims存在(有值)则渲染Account,否则渲染Auth。注意这里并未使用supabase.auth.getSession()user对象,而是读取 claims——因为后续查询profiles表需要用到 JWT 中的sub(用户 ID)作为等值条件,claims 一次读取、两处使用。

2. 订阅认证状态变化。onAuthStateChange是 Supabase JS 的全局会话监听器:当用户完成 Magic Link 登录、退出登录、令牌刷新(token refresh)时都会触发。示例在每次状态变化后重新拉取 claims,从而让视图在"已登录/未登录"之间自动切换,不需要手动刷新页面——这正是"登录后自动从登录页跳到资料页"的实现基础。

3. 为什么不直接渲染资料组件。将登录表单与资料面板拆成两个独立组件(Auth/Account),由父组件App依据认证状态做条件渲染,结构清晰、关注点分离:认证状态管理在顶层,UI 细节收敛在叶子组件。

一个隐蔽但重要的细节:示例在Account.vue中通过claims.value.sub而非auth.user().id来定位资料行。JWT 的subclaim 与auth.users.id一致,因此:

let { data, error, status } = await supabase .from('profiles') .select(`username, website, avatar_url`) .eq('id', claims.value.sub) .single()

查询用.eq('id', claims.value.sub)精确匹配当前用户,再配合.single()断言结果唯一。代码对status !== 406(PGRST116,表示未找到匹配行)做了放行处理——首次登录时 profiles 表还没有该用户的行,此时不应报错,而是静默返回,等待用户填写资料后 upsert 插入。这正是"先登录、后建档"流程的关键容错。

五、Magic Link 免密登录实现(Auth.vue)

登录表单组件 src/components/Auth.vue 完整代码如下:

<script setup> import { ref } from 'vue' import { supabase } from '../supabase' const loading = ref(false) const email = ref('') const handleLogin = async () => { try { loading.value = true const { error } = await supabase.auth.signInWithOtp({ email: email.value }) if (error) throw error alert('Check your email for the login link!') } catch (error) { if (error instanceof Error) { alert(error.message) } } finally { loading.value = false } } </script> <template> <form class="row flex-center flex" @submit.prevent="handleLogin"> <div class="col-6 form-widget"> <h1 class="header">Supabase + Vue 3</h1> <p class="description">Sign in via magic link with your email below</p> <div> <input class="inputField" type="email" placeholder="Your email" v-model="email" /> </div> <div> <input type="submit" class="button block" :value="loading ? 'Loading' : 'Send magic link'" :disabled="loading" /> </div> </div> </form> </template>

实现要点:

  • 表单通过@submit.prevent="handleLogin"拦截默认提交行为,避免页面刷新(SPA 的标配做法);
  • 核心只有一行 API 调用supabase.auth.signInWithOtp({ email })。该方法会向指定邮箱发送一封含一次性登录链接的邮件,用户点击链接即完成登录,全程无需设置与记忆密码
  • 发送成功后用alert提示用户查收邮件。由于点击邮件中的链接会回到应用并完成会话建立,App.vue中监听的onAuthStateChange随即触发,claims更新后自动切换到Account组件;
  • loading状态在请求期间禁用提交按钮,并将按钮文案切换为 "Loading",防止重复发送;
  • 若未启用邮件模板自定义,Supabase 默认会把邮件里的跳转地址指向项目的 Site URL,本地联调时需要把 Auth 设置中的 Site URL 配为http://localhost:5173,否则点击邮件链接无法正确回到本地应用。

signInWithOtp属于 Supabase Auth 的 GoTrue 客户端实现。值得一提的是,除了邮箱 OTP,该系列 API 还提供signInWithPasswordsignInWithOAuth(第三方社交登录)等,本示例刻意只选用 Magic Link,以展示"零密码、低摩擦"的登录体验。

六、资料读取、更新与退出(Account.vue)

src/components/Account.vue 承担三件事:读取资料、保存(upsert)资料、退出登录。

读取资料

async function getProfile() { try { loading.value = true let { data, error, status } = await supabase .from('profiles') .select(`username, website, avatar_url`) .eq('id', claims.value.sub) .single() if (error && status !== 406) throw error if (data) { username.value = data.username website.value = data.website avatar_url.value = data.avatar_url } } catch (error) { alert(error.message) } finally { loading.value = false } }

组件挂载后立即执行getProfile()。查询列被精确限定为username, website, avatar_url(避免拉取大字段),行级过滤交给 RLS 的公开读策略与.eq('id', ...)双重保证。对 406 状态码的容错是"首次登录尚无记录"场景的关键——没有这行判断,新用户进入页面就会被alert报错打断。

保存资料(upsert 语义)

async function updateProfile() { try { loading.value = true const updates = { id: claims.value.sub, username: username.value, website: website.value, avatar_url: avatar_url.value, updated_at: new Date(), } let { error } = await supabase.from('profiles').upsert(updates) if (error) throw error } catch (error) { alert(error.message) } finally { loading.value = false } }

保存时使用.upsert()而非.insert().update(),这是本示例的巧妙之处:

  • 若该用户尚无资料行(首次提交表单),upsert按主键id执行插入;
  • 若已有资料行,则按主键冲突执行更新;
  • 每次保存都会刷新updated_at,与数据表中updated_at timestamp with time zone字段呼应,方便后续追踪修改时间。

提交对象始终携带id: claims.value.sub,这是为了让行级写策略with check ((select auth.uid()) = id)能够校验通过。表单通过@submit.prevent="updateProfile"绑定,提交按钮根据loading显示 "Update" 或 "Loading ...",Email 输入框只读展示(邮箱不可由用户资料接口修改)。

退出登录

async function signOut() { try { loading.value = true let { error } = await supabase.auth.signOut() if (error) throw error } catch (error) { alert(error.message) } finally { loading.value = false } }

supabase.auth.signOut()会清除本地会话并通知onAuthStateChange,随后App.vueclaims被置空,视图自动切回Auth登录页,形成完整的认证闭环。

模板结构

模板把Avatar子组件与资料表单放在同一个<form>中:头像位于顶部,下方依次是只读 Email、用户名、个人网站与提交按钮,最底部是独立的 "Sign Out" 按钮。文件上传成功后通过@upload="updateProfile"触发一次资料保存,保证头像路径及时落库(详见下一节)。

七、头像上传与展示(Avatar.vue):Storage 全流程 + v-model 双向绑定

src/components/Avatar.vue 是 Storage 能力的集中体现,也是组件间通信的示范。它接收path(当前头像在桶内的对象路径)与size(展示尺寸,以 em 为单位)两个 prop,并对外抛出uploadupdate:path事件。

<script setup> import { ref, toRefs, watch } from 'vue' import { supabase } from '../supabase' const prop = defineProps(['path', 'size']) const { path, size } = toRefs(prop) const emit = defineEmits(['upload', 'update:path']) const uploading = ref(false) const src = ref('') const files = ref() const downloadImage = async () => { try { const { data, error } = await supabase.storage .from('avatars') .download(path.value) if (error) throw error src.value = URL.createObjectURL(data) } catch (error) { console.error('Error downloading image: ', error.message) } } const uploadAvatar = async (evt) => { files.value = evt.target.files try { uploading.value = true if (!files.value || files.value.length === 0) { throw new Error('You must select an image to upload.') } const file = files.value[0] const fileExt = file.name.split('.').pop() const filePath = `${Math.random()}.${fileExt}` let { error: uploadError } = await supabase.storage .from('avatars') .upload(filePath, file) if (uploadError) throw uploadError emit('update:path', filePath) emit('upload') } catch (error) { alert(error.message) } finally { uploading.value = false } } watch(path, () => { if (path.value) downloadImage() }) </script>

上传流程

  1. 通过隐藏的<input type="file" accept="image/*">选择图片,触发uploadAvatar
  2. 校验确实选择了文件;
  3. 取原文件扩展名,用Math.random()生成随机文件名拼出filePath(如0.7391...png)。随机命名可有效避免同名文件互相覆盖,这也是将随机值作为存储对象路径的常见做法——生产环境可进一步考虑用 UUID 或用户 ID 目录 + 时间戳的规范,便于审计与清理;
  4. 调用supabase.storage.from('avatars').upload(filePath, file)将文件放入avatars桶。注意此时尚未落库,数据库里并没有该头像的引用;
  5. 通过emit('update:path', filePath)把新路径写回父组件绑定的avatar_url,同时emit('upload')通知父组件。父组件Account.vue中这样绑定:
<Avatar v-model:path="avatar_url" @upload="updateProfile" size="10" />

v-model:path是 Vue 3.4+ 推荐的defineModel之外的自定义 v-model 用法:子组件update:path事件会更新父组件的avatar_url,随后@upload="updateProfile"触发资料 upsert,把新路径持久化到profiles.avatar_url。上传与入库因此被拆成两个解耦的步骤,中间由事件串起。

下载展示流程

  • 组件用watch(path, ...)监听头像路径变化:一旦path有值就调用downloadImage
  • downloadImage调用storage.from('avatars').download(path)拉取文件二进制,再用URL.createObjectURL(data)生成可被<img>引用的临时对象 URL 存入src。这样即便桶的策略不允许公开 URL 直链,也能通过客户端 SDK 展示内容;
  • 模板中src有值时渲染<img>,否则渲染一个占位空头像(.avatar.no-image),尺寸由sizeprop 以 em 控制。

组件初次挂载时父组件会先getProfile()把已保存的avatar_url传入,watch随即触发下载;上传新头像后路径变更再次触发,实现了"上传后立刻看到新头像"的即时反馈。

八、运行、构建与整体流程串联

启动命令

在克隆本仓库并进入示例目录、完成.env配置与 SQL 初始化后:

npm run dev

应用默认运行在http://localhost:5173(Vite 默认端口),此时浏览器里应出现登录表单:

  1. 输入邮箱,点击 "Send magic link";
  2. 前往邮箱点击 Supabase 发送的登录链接;
  3. 页面自动跳转到资料面板(onAuthStateChange生效),此时可上传头像、填写用户名与个人网站并保存;
  4. 点击 "Sign Out" 回到登录页,完成一次完整的登录→建档→登出闭环。

生产构建与本地预览分别使用npm run buildnpm run preview(见 package.json)。

一次完整保存请求的调用链回溯

结合前述源码,把"用户点击 Update 保存资料"在整条链路上的数据流串起来:

  1. Account.vue 的updateProfile()组装updates(含id = claims.sub);
  2. supabase.from('profiles').upsert(updates)发送请求,PostgREST 依据 URL 上的Authorization: Bearer <JWT>执行 SQL;
  3. 数据库侧,RLS 策略with check ((select auth.uid()) = id)校验 JWT 对应用户与写入行id一致,通过后写入或更新profiles
  4. 若此前刚上传过头像,则 Avatar.vue 已先把文件写入avatars桶(存储 RLSbucket_id = 'avatars'校验),avatar_url只是数据库中的一个文本路径;
  5. 整个写操作没有越过 RLS 的 Service Role 参与,浏览器端持有的始终是 Publishable Key。

这条链路清晰展示了 Supabase"Auth 出身份(JWT sub)、Postgres RLS 定权限、Storage 管文件、前端 SDK 做编排"的安全架构范式,也正是本示例最值得复用到真实业务中的部分。

九、扩展建议与生产化注意事项

示例定位是"quick sample"(快速起步模板),聚焦演示而非完整产品,投入到生产前通常还需要补齐以下能力:

  • 头像上传的健壮性:对文件类型与大小做前端预检,并在数据库/存储层叠加更细的约束与清理策略;随机文件名的写法可替换为更规范的路径规划;用signUrl/公开 URL 或 CDN 缓存替代每次download拉取,降低请求开销。
  • 表单校验与反馈:示例用alert()做错误提示,用户名长度依赖数据库check约束兜底;生产可引入表单校验库并在 UI 内联展示错误,同时处理username唯一冲突(可捕获唯一约束错误提示用户换名)。
  • 登录能力增强:可补充signInWithOAuth(GitHub/Google 等)、密码重置流程与邮箱确认页定制。
  • Realtime 的应用:建表脚本已把profiles加入supabase_realtime发布,前端可在此基础上订阅资料变更,实现多标签页/多端即时同步。
  • 状态管理升级:示例用极简reactivestore 演示,页面复杂后可平滑迁移到 Pinia,supabase.js客户端单例模式无需改动。
  • 补充 delete 策略:当前 schema 未给profiles定义删除策略,若产品需要"删除账户"能力,需显式添加对应 RLS 策略。

十、参考文件索引

  • 示例说明与完整建表 SQL:examples/user-management/vue3-user-management/README.md
  • 依赖与脚本定义:package.json
  • Vite 构建配置:vite.config.js
  • Supabase 客户端创建与环境变量读取:src/supabase.js
  • 应用入口与登录态分流:src/main.js、src/App.vue
  • 登录 / 资料 / 头像三组件:Auth.vue、Account.vue、Avatar.vue
  • 全局响应式状态示例:src/store.js

【免费下载链接】supabaseThe Postgres development platform. Supabase gives you a dedicated Postgres database to build your web, mobile, and AI applications.项目地址: https://gitcode.com/GitHub_Trending/supa/supabase

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询