快速开始

使用 SolidJS 构建用户管理应用


本教程展示如何构建一个基本的用户管理应用程序。该应用程序对用户进行身份验证和识别,将其个人资料信息存储在数据库中,并允许用户登录、更新个人资料详细信息以及上传个人资料照片。该应用程序使用:

Supabase 用户管理示例

项目设置

在开始构建之前,我们需要设置数据库和 API。这就像在 Supabase 中创建一个新项目,然后在数据库中创建一个 “模式” 一样简单。

创建项目

  1. 在 Supabase 仪表板中创建一个新项目
  2. 输入项目详细信息。
  3. 等待新数据库启动。

设置数据库模式

现在我们要设置数据库模式。我们可以在 SQL 编辑器中使用 “用户管理入门” 快速启动模板,或者您也可以直接复制/粘贴下面的 SQL 并自行运行。

  1. 转到仪表板中的SQL 编辑器页面。
  2. 点击 用户管理入门
  3. 点击 运行
1
2
3
4
supabase link --project-ref <project-id># 您可以从项目的仪表板 URL 获取 <project-id>:https://supabase.com/dashboard/project/<project-id>supabase db pull

获取 API 密钥

既然您已经创建了一些数据库表,就可以使用自动生成的 API 插入数据了。 我们只需要从 API 设置中获取项目 URL 和 anon 密钥。

  1. 前往仪表板中的 API 设置 页面。
  2. 在该页面找到您的项目 URLanonservice_role 密钥。

构建应用

让我们从零开始构建一个 SolidJS 应用。

初始化 SolidJS 应用

我们可以使用 degit 初始化一个名为 supabase-solid 的应用:

1
2
npx degit solidjs/templates/ts supabase-solidcd supabase-solid

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

1
npm install @supabase/supabase-js

最后我们需要将环境变量保存在 .env 文件中。 只需要之前获取的 API URL 和 anon 密钥。

1
2
VITE_SUPABASE_URL=YOUR_SUPABASE_URLVITE_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY

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

1
2
3
4
5
6
import { createClient } from '@supabase/supabase-js'const supabaseUrl = import.meta.env.VITE_SUPABASE_URLconst supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEYexport const supabase = createClient(supabaseUrl, supabaseAnonKey)

应用样式(可选)

可选步骤是更新 CSS 文件 src/index.css 让应用看起来更美观。 你可以在这里找到这个文件的完整内容

设置登录组件

让我们设置一个 SolidJS 组件来管理登录和注册。我们将使用 Magic Links(魔法链接),这样用户无需密码即可通过电子邮件登录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import { createSignal } from 'solid-js'import { supabase } from './supabaseClient'export default function Auth() { const [loading, setLoading] = createSignal(false) const [email, setEmail] = createSignal('') const handleLogin = async (e: SubmitEvent) => { e.preventDefault() try { setLoading(true) const { error } = await supabase.auth.signInWithOtp({ email: email() }) if (error) throw error alert('请查看您的邮箱获取登录链接!') } catch (error) { if (error instanceof Error) { alert(error.message) } } finally { setLoading(false) } } return ( <div class="row flex-center flex"> <div class="col-6 form-widget" aria-live="polite"> <h1 class="header">Supabase + SolidJS</h1> <p class="description">通过下方邮箱接收魔法链接登录</p> <form class="form-widget" onSubmit={handleLogin}> <div> <label for="email">邮箱</label> <input id="email" class="inputField" type="email" placeholder="您的邮箱" value={email()} onChange={(e) => setEmail(e.currentTarget.value)} /> </div> <div> <button type="submit" class="button block" aria-live="polite"> {loading() ? <span>加载中</span> : <span>发送魔法链接</span>} </button> </div> </form> </div> </div> )}

账户页面

当用户登录后,我们可以允许他们编辑个人资料信息和管理账户。

让我们创建一个名为 Account.tsx 的新组件来实现这个功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
import { AuthSession } from '@supabase/supabase-js'import { Component, createEffect, createSignal } from 'solid-js'import { supabase } from './supabaseClient'interface Props { session: AuthSession}const Account: Component<Props> = ({ session }) => { const [loading, setLoading] = createSignal(true) const [username, setUsername] = createSignal<string | null>(null) const [website, setWebsite] = createSignal<string | null>(null) const [avatarUrl, setAvatarUrl] = createSignal<string | null>(null) createEffect(() => { getProfile() }) const getProfile = async () => { try { setLoading(true) const { user } = session 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) { setUsername(data.username) setWebsite(data.website) setAvatarUrl(data.avatar_url) } } catch (error) { if (error instanceof Error) { alert(error.message) } } finally { setLoading(false) } } const updateProfile = async (e: Event) => { e.preventDefault() try { setLoading(true) const { user } = session const updates = { id: user.id, username: username(), website: website(), avatar_url: avatarUrl(), updated_at: new Date().toISOString(), } const { error } = await supabase.from('profiles').upsert(updates) if (error) { throw error } } catch (error) { if (error instanceof Error) { alert(error.message) } } finally { setLoading(false) } } return ( <div aria-live="polite"> <form onSubmit={updateProfile} class="form-widget"> <div>邮箱: {session.user.email}</div> <div> <label for="username">用户名</label> <input id="username" type="text" value={username() || ''} onChange={(e) => setUsername(e.currentTarget.value)} /> </div> <div> <label for="website">网站</label> <input id="website" type="text" value={website() || ''} onChange={(e) => setWebsite(e.currentTarget.value)} /> </div> <div> <button type="submit" class="button primary block" disabled={loading()}> {loading() ? '保存中...' : '更新资料'} </button> </div> <button type="button" class="button block" onClick={() => supabase.auth.signOut()}> 退出登录 </button> </form> </div> )}export default Account

启动应用!

现在我们已经准备好了所有组件,让我们更新 App.tsx 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { Component, createEffect, createSignal } from 'solid-js'import { supabase } from './supabaseClient'import { AuthSession } from '@supabase/supabase-js'import Account from './Account'import Auth from './Auth'const App: Component = () => { const [session, setSession] = createSignal<AuthSession | null>(null) createEffect(() => { supabase.auth.getSession().then(({ data: { session } }) => { setSession(session) }) supabase.auth.onAuthStateChange((_event, session) => { setSession(session) }) }) return ( <div class="container" style={{ padding: '50px 0 100px 0' }}> {!session() ? <Auth /> : <Account session={session()!} />} </div> )}export default App

完成后,在终端窗口中运行以下命令:

1
npm start

然后在浏览器中打开 localhost:3000,您应该能看到完整的应用程序。

Supabase SolidJS

额外功能:个人资料照片

每个 Supabase 项目都配置了存储功能,用于管理照片和视频等大型文件。

创建上传组件

让我们为用户创建一个头像组件,使其能够上传个人资料照片。我们可以从创建一个新组件开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
import { Component, createEffect, createSignal, JSX } from 'solid-js'import { supabase } from './supabaseClient'interface Props { size: number url: string | null onUpload: (event: Event, filePath: string) => void}const Avatar: Component<Props> = (props) => { const [avatarUrl, setAvatarUrl] = createSignal<string | null>(null) const [uploading, setUploading] = createSignal(false) createEffect(() => { if (props.url) downloadImage(props.url) }) 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) { if (error instanceof Error) { console.log('下载图片时出错: ', error.message) } } } const uploadAvatar: JSX.EventHandler<HTMLInputElement, Event> = async (event) => { try { setUploading(true) const target = event.currentTarget if (!target?.files || target.files.length === 0) { throw new Error('必须选择要上传的图片') } const file = target.files[0] const fileExt = file.name.split('.').pop() const fileName = `${Math.random()}.${fileExt}` const filePath = `${fileName}` const { error: uploadError } = await supabase.storage.from('avatars').upload(filePath, file) if (uploadError) { throw uploadError } props.onUpload(event, filePath) } catch (error) { if (error instanceof Error) { alert(error.message) } } finally { setUploading(false) } } return ( <div style={{ width: `${props.size}px` }} aria-live="polite"> {avatarUrl() ? ( <img src={avatarUrl()!} alt={avatarUrl() ? '头像' : '无图片'} class="avatar image" style={{ height: `${props.size}px`, width: `${props.size}px` }} /> ) : ( <div class="avatar no-image" style={{ height: `${props.size}px`, width: `${props.size}px` }} /> )} <div style={{ width: `${props.size}px` }}> <label class="button primary block" for="single"> {uploading() ? '上传中...' : '上传头像'} </label> <span style="display:none"> <input type="file" id="single" accept="image/*" onChange={uploadAvatar} disabled={uploading()} /> </span> </div> </div> )}export default Avatar

添加新组件

然后我们可以将组件添加到账户页面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 导入新组件import Avatar from './Avatar'// ...return ( <form onSubmit={updateProfile} class="form-widget"> {/* 添加到主体部分 */} <Avatar url={avatarUrl()} size={150} onUpload={(e: Event, url: string) => { setAvatarUrl(url) updateProfile(e) }} /> {/* ... */} </div>)

至此,您已经拥有一个功能完整的应用程序!