跳转到主要内容
PocketBase.cn
教程 精选

Vue 3 + PocketBase 完整集成教程

从零开始学习如何在 Vue 3 项目中集成 PocketBase,包含认证、CRUD 操作、实时功能、状态管理和生产部署的完整指南。

PocketBase.cn
· · 更新于 2024年12月20日

概述

本教程将指导你完成 Vue 3 与 PocketBase 的完整集成,涵盖从项目初始化到生产部署的全部流程。我们将使用 Vue 3 Composition API、Pinia 状态管理,以及 PocketBase JavaScript SDK。

技术栈

  • Vue 3 - 渐进式 JavaScript 框架
  • PocketBase - 开源后端解决方案
  • Pinia - Vue 状态管理
  • Vue Router - 路由管理
  • TypeScript - 类型安全
  • Vite - 构建工具

目录

  1. 项目初始化
  2. 配置 PocketBase
  3. 用户认证
  4. CRUD 操作
  5. 实时功能
  6. 文件上传
  7. 状态管理
  8. 路由守卫
  9. 生产部署

项目初始化

1. 创建 Vue 项目

Terminal window
# 使用 Vite 创建 Vue 3 + TypeScript 项目
npm create vite@latest vue-pocketbase-app -- --template vue-ts
cd vue-pocketbase-app
# 安装依赖
npm install
# 安装 PocketBase SDK
npm install pocketbase
# 安装 Pinia 和 Vue Router
npm install pinia vue-router

2. 项目结构

src/
├── api/ # API 模块
│ ├── index.ts # PocketBase 实例
│ ├── auth.ts # 认证相关 API
│ └── posts.ts # 业务 API 示例
├── stores/ # Pinia stores
│ ├── auth.ts # 认证状态
│ └── posts.ts # 业务状态
├── composables/ # Composables
│ ├── useAuth.ts # 认证逻辑
│ └── usePosts.ts # 业务逻辑
├── router/ # 路由配置
│ └── index.ts
├── views/ # 页面组件
│ ├── Home.vue
│ ├── Login.vue
│ ├── Register.vue
│ └── Dashboard.vue
├── components/ # 通用组件
├── types/ # TypeScript 类型
│ └── pocketbase.ts
└── main.ts

配置 PocketBase

1. 创建环境变量

创建 .env 文件:

VITE_PB_URL=http://localhost:8090
VITE_PB_URL_PROD=https://api.yourserver.com

创建 .env.production 文件:

VITE_PB_URL=https://api.yourserver.com

2. 初始化 PocketBase 实例

src/api/index.ts

import PocketBase from "pocketbase";
const pbUrl = import.meta.env.VITE_PB_URL || "http://localhost:8090";
export const pb = new PocketBase(pbUrl);
// 开发环境下启用日志
if (import.meta.env.DEV) {
pb.autoCancellation(false);
}
export default pb;

3. 定义 TypeScript 类型

src/types/pocketbase.ts

import type { ListResult, RecordModel } from "pocketbase";
// 扩展用户模型
export interface UserResponse extends RecordModel {
id: string;
collectionId: string;
collectionName: string;
username: string;
email: string;
name: string;
avatar?: string;
role: "user" | "admin";
created: string;
updated: string;
}
// 文章模型
export interface PostResponse extends RecordModel {
id: string;
collectionId: string;
collectionName: string;
title: string;
content: string;
excerpt: string;
status: "draft" | "published";
category: string;
tags: string[];
author: string; // 关联用户 ID
authorData?: UserResponse;
views: number;
created: string;
updated: string;
}
// 认证响应
export interface AuthResponse {
token: string;
record: UserResponse;
}
// 分页参数
export interface ListParams {
page?: number;
perPage?: number;
filter?: string;
sort?: string;
expand?: string;
}
// 扩展列表结果
export interface PaginatedList<T> extends ListResult<T> {
items: T[];
}

4. 配置应用入口

src/main.ts

import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
import "./assets/main.css";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount("#app");

用户认证

1. 创建认证 API

src/api/auth.ts

import { pb } from "./index";
import type { AuthResponse, UserResponse } from "@/types/pocketbase";
export interface LoginCredentials {
email: string;
password: string;
}
export interface RegisterData {
email: string;
password: string;
passwordConfirm: string;
name: string;
}
export interface UpdateProfileData {
name?: string;
avatar?: File;
}
// 登录
export async function login(
credentials: LoginCredentials,
): Promise<AuthResponse> {
return await pb
.collection("users")
.authWithPassword(credentials.email, credentials.password);
}
// 注册
export async function register(data: RegisterData): Promise<UserResponse> {
return await pb.collection("users").create(data);
}
// 登出
export function logout(): void {
pb.authStore.clear();
}
// 获取当前用户
export function getCurrentUser(): UserResponse | null {
return pb.authStore.model as UserResponse | null;
}
// 检查是否已登录
export function isAuthenticated(): boolean {
return pb.authStore.isValid;
}
// 更新用户资料
export async function updateProfile(
data: UpdateProfileData,
): Promise<UserResponse> {
const userId = pb.authStore.model?.id;
if (!userId) throw new Error("未登录");
const formData = new FormData();
if (data.name) formData.append("name", data.name);
if (data.avatar) formData.append("avatar", data.avatar);
return await pb.collection("users").update(userId, formData);
}
// 修改密码
export async function changePassword(
oldPassword: string,
password: string,
passwordConfirm: string,
): Promise<void> {
const userId = pb.authStore.model?.id;
if (!userId) throw new Error("未登录");
await pb.collection("users").update(userId, {
oldPassword,
password,
passwordConfirm,
});
}
// 发送密码重置邮件
export async function requestPasswordReset(email: string): Promise<void> {
await pb.collection("users").requestPasswordReset(email);
}
// 确认密码重置
export async function confirmPasswordReset(
token: string,
password: string,
passwordConfirm: string,
): Promise<void> {
await pb
.collection("users")
.confirmPasswordReset(token, password, passwordConfirm);
}
// OAuth 登录
export async function loginWithOAuth(
provider: "google" | "github" | "facebook",
callbackURL: string,
): Promise<void> {
await pb.collection("users").authWithOAuth2({
provider,
callbackURL,
urlCallback: (url) => {
window.location.href = url;
},
});
}

2. 创建认证 Store

src/stores/auth.ts

import { defineStore } from "pinia";
import {
login,
logout as apiLogout,
getCurrentUser,
isAuthenticated,
updateProfile,
changePassword,
} from "@/api/auth";
import type {
UserResponse,
LoginCredentials,
RegisterData,
} from "@/types/pocketbase";
export const useAuthStore = defineStore("auth", {
state: () => ({
user: getCurrentUser(),
isLoading: false,
error: null as string | null,
}),
getters: {
isAuthenticated: (state) => !!state.user && isAuthenticated(),
userName: (state) => state.user?.name || "",
userRole: (state) => state.user?.role || "user",
isAdmin: (state) => state.user?.role === "admin",
},
actions: {
async login(credentials: LoginCredentials) {
this.isLoading = true;
this.error = null;
try {
const authData = await login(credentials);
this.user = authData.record;
// 持久化认证状态
localStorage.setItem(
"pocketbase_auth",
JSON.stringify({
token: authData.token,
user: authData.record,
}),
);
return authData;
} catch (error: any) {
this.error = error.message || "登录失败";
throw error;
} finally {
this.isLoading = false;
}
},
async logout() {
apiLogout();
this.user = null;
localStorage.removeItem("pocketbase_auth");
},
async updateUser(data: FormData) {
this.isLoading = true;
try {
const updated = await updateProfile({
name: data.get("name") as string,
avatar: data.get("avatar") as File,
});
this.user = updated;
} finally {
this.isLoading = false;
}
},
},
});

3. 创建认证 Composable

src/composables/useAuth.ts

import { computed } from "vue";
import { useAuthStore } from "@/stores/auth";
export function useAuth() {
const authStore = useAuthStore();
const login = async (email: string, password: string) => {
return await authStore.login({ email, password });
};
const logout = () => {
authStore.logout();
};
return {
// 状态
user: computed(() => authStore.user),
isLoading: computed(() => authStore.isLoading),
error: computed(() => authStore.error),
// 计算属性
isAuthenticated: computed(() => authStore.isAuthenticated),
userName: computed(() => authStore.userName),
isAdmin: computed(() => authStore.isAdmin),
// 方法
login,
logout,
};
}

4. 创建登录页面

src/views/Login.vue

<template>
<div
class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8"
>
<div class="max-w-md w-full space-y-8">
<div>
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
登录到您的账户
</h2>
</div>
<form class="mt-8 space-y-6" @submit.prevent="handleSubmit">
<div v-if="error" class="rounded-md bg-red-50 p-4">
<p class="text-sm text-red-800">{{ error }}</p>
</div>
<div class="rounded-md shadow-sm -space-y-px">
<div>
<label for="email" class="sr-only">邮箱地址</label>
<input
id="email"
v-model="form.email"
type="email"
required
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="邮箱地址"
/>
</div>
<div>
<label for="password" class="sr-only">密码</label>
<input
id="password"
v-model="form.password"
type="password"
required
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="密码"
/>
</div>
</div>
<div>
<button
type="submit"
:disabled="isLoading"
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span v-if="isLoading">登录中...</span>
<span v-else>登录</span>
</button>
</div>
<div class="text-center">
<router-link
to="/register"
class="font-medium text-indigo-600 hover:text-indigo-500"
>
还没有账户?立即注册
</router-link>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import { useRouter } from "vue-router";
import { useAuth } from "@/composables/useAuth";
const router = useRouter();
const { login, error, isLoading } = useAuth();
const form = ref({
email: "",
password: "",
});
const handleSubmit = async () => {
try {
await login(form.value.email, form.value.password);
router.push("/dashboard");
} catch (err) {
// 错误已在 composable 中处理
}
};
</script>

CRUD 操作

1. 创建文章 API

src/api/posts.ts

import { pb } from "./index";
import type {
PostResponse,
ListParams,
PaginatedList,
} from "@/types/pocketbase";
// 获取文章列表
export async function getPosts(
params: ListParams = {},
): Promise<PaginatedList<PostResponse>> {
const {
page = 1,
perPage = 20,
filter = "",
sort = "-created",
expand = "author",
} = params;
return await pb.collection("posts").getList(page, perPage, {
filter,
sort,
expand,
});
}
// 获取单篇文章
export async function getPost(id: string): Promise<PostResponse> {
return await pb.collection("posts").getOne(id, {
expand: "author",
});
}
// 创建文章
export async function createPost(data: {
title: string;
content: string;
excerpt: string;
status: "draft" | "published";
category: string;
tags: string[];
}): Promise<PostResponse> {
return await pb.collection("posts").create(data);
}
// 更新文章
export async function updatePost(
id: string,
data: Partial<PostResponse>,
): Promise<PostResponse> {
return await pb.collection("posts").update(id, data);
}
// 删除文章
export async function deletePost(id: string): Promise<void> {
await pb.collection("posts").delete(id);
}
// 获取已发布文章
export async function getPublishedPosts(
params: ListParams = {},
): Promise<PaginatedList<PostResponse>> {
return getPosts({
...params,
filter: 'status = "published"',
});
}
// 搜索文章
export async function searchPosts(query: string): Promise<PostResponse[]> {
return await pb.collection("posts").getFullList({
filter: `title ~ "${query}" || content ~ "${query}"`,
sort: "-created",
});
}
// 按分类获取文章
export async function getPostsByCategory(
category: string,
): Promise<PostResponse[]> {
return await pb.collection("posts").getFullList({
filter: `category = "${category}" && status = "published"`,
sort: "-created",
});
}

2. 创建文章 Composable

src/composables/usePosts.ts

import { ref, computed } from "vue";
import {
getPosts,
getPost,
createPost,
updatePost,
deletePost,
getPublishedPosts,
} from "@/api/posts";
import type { PostResponse, ListParams } from "@/types/pocketbase";
export function usePosts(initialParams: ListParams = {}) {
const posts = ref<PostResponse[]>([]);
const totalItems = ref(0);
const currentPage = ref(1);
const perPage = ref(initialParams.perPage || 20);
const isLoading = ref(false);
const error = ref<string | null>(null);
// 获取文章列表
const fetchPosts = async (params: ListParams = {}) => {
isLoading.value = true;
error.value = null;
try {
const result = await getPosts({
page: currentPage.value,
perPage: perPage.value,
...initialParams,
...params,
});
posts.value = result.items;
totalItems.value = result.totalItems;
currentPage.value = result.page;
} catch (e: any) {
error.value = e.message || "获取文章列表失败";
} finally {
isLoading.value = false;
}
};
// 获取单篇文章
const fetchPost = async (id: string) => {
isLoading.value = true;
error.value = null;
try {
return await getPost(id);
} catch (e: any) {
error.value = e.message || "获取文章失败";
throw e;
} finally {
isLoading.value = false;
}
};
// 创建文章
const createNewPost = async (
data: Omit<PostResponse, "id" | "created" | "updated">,
) => {
isLoading.value = true;
error.value = null;
try {
const newPost = await createPost(data as any);
posts.value.unshift(newPost);
return newPost;
} catch (e: any) {
error.value = e.message || "创建文章失败";
throw e;
} finally {
isLoading.value = false;
}
};
// 更新文章
const updateExistingPost = async (
id: string,
data: Partial<PostResponse>,
) => {
isLoading.value = true;
error.value = null;
try {
const updated = await updatePost(id, data);
const index = posts.value.findIndex((p) => p.id === id);
if (index !== -1) {
posts.value[index] = updated;
}
return updated;
} catch (e: any) {
error.value = e.message || "更新文章失败";
throw e;
} finally {
isLoading.value = false;
}
};
// 删除文章
const removePost = async (id: string) => {
isLoading.value = true;
error.value = null;
try {
await deletePost(id);
posts.value = posts.value.filter((p) => p.id !== id);
} catch (e: any) {
error.value = e.message || "删除文章失败";
throw e;
} finally {
isLoading.value = false;
}
};
// 分页
const totalPages = computed(() =>
Math.ceil(totalItems.value / perPage.value),
);
const hasNextPage = computed(() => currentPage.value < totalPages.value);
const hasPrevPage = computed(() => currentPage.value > 1);
const nextPage = () => {
if (hasNextPage.value) {
currentPage.value++;
fetchPosts();
}
};
const prevPage = () => {
if (hasPrevPage.value) {
currentPage.value--;
fetchPosts();
}
};
const goToPage = (page: number) => {
currentPage.value = page;
fetchPosts();
};
// 初始加载
fetchPosts();
return {
// 状态
posts: computed(() => posts.value),
totalItems: computed(() => totalItems.value),
currentPage: computed(() => currentPage.value),
totalPages,
hasNextPage,
hasPrevPage,
isLoading: computed(() => isLoading.value),
error: computed(() => error.value),
// 方法
fetchPosts,
fetchPost,
createNewPost,
updateExistingPost,
removePost,
nextPage,
prevPage,
goToPage,
};
}

3. 创建文章列表页面

src/views/Posts.vue

<template>
<div class="container mx-auto px-4 py-8">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">文章列表</h1>
<router-link
to="/posts/new"
class="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700"
>
新建文章
</router-link>
</div>
<div v-if="isLoading" class="text-center py-8">
<div
class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"
></div>
</div>
<div v-else-if="error" class="bg-red-50 text-red-800 p-4 rounded">
{{ error }}
</div>
<div v-else-if="posts.length === 0" class="text-center py-8 text-gray-500">
暂无文章
</div>
<div v-else>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div
v-for="post in posts"
:key="post.id"
class="bg-white rounded-lg shadow hover:shadow-md transition-shadow"
>
<img
v-if="post.image"
:src="pb.files.getUrl(post, post.image)"
class="w-full h-48 object-cover rounded-t-lg"
/>
<div class="p-4">
<span class="text-sm text-indigo-600">{{ post.category }}</span>
<h3 class="text-lg font-semibold mt-1">
<router-link :to="`/posts/${post.id}`">
{{ post.title }}
</router-link>
</h3>
<p class="text-gray-600 mt-2 line-clamp-2">{{ post.excerpt }}</p>
<div
class="flex items-center justify-between mt-4 text-sm text-gray-500"
>
<span>{{ formatDate(post.created) }}</span>
<span>{{ post.views }} 次浏览</span>
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div v-if="totalPages > 1" class="flex justify-center gap-2 mt-8">
<button
@click="prevPage"
:disabled="!hasPrevPage"
class="px-4 py-2 border rounded disabled:opacity-50"
>
上一页
</button>
<span class="px-4 py-2"> {{ currentPage }} / {{ totalPages }} </span>
<button
@click="nextPage"
:disabled="!hasNextPage"
class="px-4 py-2 border rounded disabled:opacity-50"
>
下一页
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { usePosts } from "@/composables/usePosts";
import { pb } from "@/api";
const {
posts,
currentPage,
totalPages,
hasNextPage,
hasPrevPage,
isLoading,
error,
nextPage,
prevPage,
} = usePosts({ perPage: 12 });
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString("zh-CN");
};
</script>

4. 创建文章编辑页面

src/views/PostEdit.vue

<template>
<div class="container mx-auto px-4 py-8 max-w-4xl">
<h1 class="text-2xl font-bold mb-6">
{{ isEditing ? "编辑文章" : "新建文章" }}
</h1>
<form @submit.prevent="handleSubmit" class="space-y-6">
<div>
<label class="block text-sm font-medium text-gray-700">标题</label>
<input
v-model="form.title"
type="text"
required
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">摘要</label>
<textarea
v-model="form.excerpt"
rows="2"
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">内容</label>
<textarea
v-model="form.content"
rows="15"
required
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
></textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">分类</label>
<select
v-model="form.category"
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="">选择分类</option>
<option value="tech">技术</option>
<option value="life">生活</option>
<option value="thoughts">随想</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">状态</label>
<select
v-model="form.status"
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="draft">草稿</option>
<option value="published">已发布</option>
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700"
>标签(逗号分隔)</label
>
<input
v-model="tagsInput"
type="text"
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="vue, pocketbase, tutorial"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">封面图片</label>
<input
type="file"
@change="handleFileChange"
accept="image/*"
class="mt-1 block w-full"
/>
<div v-if="previewImage" class="mt-2">
<img :src="previewImage" class="h-32 rounded" />
</div>
</div>
<div class="flex gap-4">
<button
type="submit"
:disabled="isSaving"
class="px-6 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700 disabled:opacity-50"
>
{{ isSaving ? "保存中..." : "保存" }}
</button>
<button
type="button"
@click="$router.back()"
class="px-6 py-2 border border-gray-300 rounded hover:bg-gray-50"
>
取消
</button>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useRouter, useRoute } from "vue-router";
import { usePosts } from "@/composables/usePosts";
import { pb } from "@/api";
const router = useRouter();
const route = useRoute();
const { createNewPost, updateExistingPost, fetchPost } = usePosts();
const isEditing = computed(() => !!route.params.id);
const isSaving = ref(false);
const form = ref({
title: "",
excerpt: "",
content: "",
category: "",
status: "draft" as "draft" | "published",
});
const tagsInput = ref("");
const imageFile = ref<File | null>(null);
const previewImage = ref("");
const handleSubmit = async () => {
isSaving.value = true;
try {
const tags = tagsInput.value
.split(",")
.map((t) => t.trim())
.filter(Boolean);
const formData = new FormData();
formData.append("title", form.value.title);
formData.append("excerpt", form.value.excerpt);
formData.append("content", form.value.content);
formData.append("category", form.value.category);
formData.append("status", form.value.status);
tags.forEach((tag) => formData.append("tags", tag));
if (imageFile.value) {
formData.append("image", imageFile.value);
}
if (isEditing.value) {
await updateExistingPost(
route.params.id as string,
Object.fromEntries(formData),
);
} else {
await createNewPost(Object.fromEntries(formData) as any);
}
router.push("/posts");
} catch (err) {
console.error("保存失败:", err);
} finally {
isSaving.value = false;
}
};
const handleFileChange = (e: Event) => {
const target = e.target as HTMLInputElement;
if (target.files?.[0]) {
imageFile.value = target.files[0];
previewImage.value = URL.createObjectURL(target.files[0]);
}
};
onMounted(async () => {
if (isEditing.value) {
const post = await fetchPost(route.params.id as string);
form.value = {
title: post.title,
excerpt: post.excerpt,
content: post.content,
category: post.category,
status: post.status,
};
tagsInput.value = post.tags.join(", ");
if (post.image) {
previewImage.value = pb.files.getUrl(post, post.image);
}
}
});
</script>

实时功能

1. 创建实时订阅 Composable

src/composables/useRealtime.ts

import { ref, onUnmounted } from "vue";
import { pb } from "@/api";
type SubscriptionCallback = (data: {
action: "create" | "update" | "delete";
record: any;
}) => void;
export function useRealtime(collection: string) {
const isConnected = ref(false);
const callbacks = new Set<SubscriptionCallback>();
let unsubscribe: (() => void) | null = null;
const subscribe = (callback: SubscriptionCallback) => {
callbacks.add(callback);
// 首次订阅时建立连接
if (callbacks.size === 1) {
pb.collection(collection).subscribe(
"*",
(e) => {
isConnected.value = true;
callbacks.forEach((cb) => cb(e));
},
{
// 自动重连
expand: "author",
},
);
}
};
const unsubscribeAll = () => {
callbacks.clear();
if (unsubscribe) {
unsubscribe();
unsubscribe = null;
}
pb.collection(collection).unsubscribe();
isConnected.value = false;
};
// 组件卸载时取消订阅
onUnmounted(() => {
unsubscribeAll();
});
return {
isConnected,
subscribe,
unsubscribe: unsubscribeAll,
};
}
// 专用文章实时订阅
export function usePostsRealtime() {
const { isConnected, subscribe, unsubscribe } = useRealtime("posts");
const newPosts = ref<any[]>([]);
const onCreate = (callback: (post: any) => void) => {
subscribe((e) => {
if (e.action === "create") {
newPosts.value.unshift(e.record);
callback(e.record);
}
});
};
const onUpdate = (callback: (post: any) => void) => {
subscribe((e) => {
if (e.action === "update") {
callback(e.record);
}
});
};
const onDelete = (callback: (postId: string) => void) => {
subscribe((e) => {
if (e.action === "delete") {
callback(e.record.id);
}
});
};
return {
isConnected,
newPosts,
onCreate,
onUpdate,
onDelete,
unsubscribe,
};
}

2. 实时通知组件

src/components/RealtimeNotifications.vue

<template>
<div
v-if="notifications.length > 0"
class="fixed bottom-4 right-4 z-50 space-y-2"
>
<TransitionGroup name="notification">
<div
v-for="notification in notifications"
:key="notification.id"
:class="[
'p-4 rounded-lg shadow-lg max-w-sm',
notification.type === 'create'
? 'bg-green-50 border border-green-200'
: notification.type === 'update'
? 'bg-blue-50 border border-blue-200'
: 'bg-red-50 border border-red-200',
]"
>
<div class="flex items-start">
<div class="flex-shrink-0">
<svg
v-if="notification.type === 'create'"
class="h-5 w-5 text-green-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V7z"
clip-rule="evenodd"
/>
</svg>
<svg
v-else-if="notification.type === 'update'"
class="h-5 w-5 text-blue-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"
/>
</svg>
<svg
v-else
class="h-5 w-5 text-red-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3 flex-1">
<p class="text-sm font-medium text-gray-900">
{{ notification.title }}
</p>
<p class="mt-1 text-sm text-gray-500">{{ notification.message }}</p>
</div>
<button @click="removeNotification(notification.id)" class="ml-3">
<svg
class="h-5 w-5 text-gray-400 hover:text-gray-600"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
</div>
</TransitionGroup>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { usePostsRealtime } from "@/composables/useRealtime";
const notifications = ref<
Array<{
id: string;
type: "create" | "update" | "delete";
title: string;
message: string;
}>
>([]);
const { onCreate, onUpdate, onDelete } = usePostsRealtime();
onCreate((post) => {
notifications.value.push({
id: Date.now().toString(),
type: "create",
title: "新文章发布",
message: `${post.title}》刚刚发布`,
});
setTimeout(() => {
notifications.value.shift();
}, 5000);
});
onUpdate((post) => {
notifications.value.push({
id: Date.now().toString(),
type: "update",
title: "文章已更新",
message: `${post.title}》已被更新`,
});
setTimeout(() => {
notifications.value.shift();
}, 5000);
});
onDelete((postId) => {
notifications.value.push({
id: Date.now().toString(),
type: "delete",
title: "文章已删除",
message: `一篇文章已被删除`,
});
setTimeout(() => {
notifications.value.shift();
}, 5000);
});
const removeNotification = (id: string) => {
const index = notifications.value.findIndex((n) => n.id === id);
if (index !== -1) {
notifications.value.splice(index, 1);
}
};
</script>
<style>
.notification-enter-active,
.notification-leave-active {
transition: all 0.3s ease;
}
.notification-enter-from {
opacity: 0;
transform: translateX(100%);
}
.notification-leave-to {
opacity: 0;
transform: translateX(100%);
}
</style>

文件上传

1. 文件上传 Composable

src/composables/useFileUpload.ts

import { ref } from "vue";
import { pb } from "@/api";
interface UploadOptions {
onProgress?: (percent: number) => void;
}
export function useFileUpload() {
const isUploading = ref(false);
const progress = ref(0);
const error = ref<string | null>(null);
const uploadFile = async (
collection: string,
recordId: string,
field: string,
file: File,
options?: UploadOptions,
) => {
isUploading.value = true;
progress.value = 0;
error.value = null;
try {
const formData = new FormData();
formData.append(field, file);
const result = await pb
.collection(collection)
.update(recordId, formData, {
// 请求/响应配置(如果支持进度)
});
return result;
} catch (e: any) {
error.value = e.message || "上传失败";
throw e;
} finally {
isUploading.value = false;
}
};
const uploadMultiple = async (
files: File[],
options?: UploadOptions,
): Promise<string[]> => {
const urls: string[] = [];
for (let i = 0; i < files.length; i++) {
const percent = Math.round(((i + 1) / files.length) * 100);
options?.onProgress?.(percent);
// 实现上传逻辑
}
return urls;
};
const getImageUrl = (record: any, filename: string, size?: string) => {
return pb.files.getUrl(
record,
filename,
size ? { thumb: size } : undefined,
);
};
return {
isUploading,
progress,
error,
uploadFile,
uploadMultiple,
getImageUrl,
};
}

2. 图片上传组件

src/components/ImageUpload.vue

<template>
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">
{{ label }}
</label>
<div class="border-2 border-dashed border-gray-300 rounded-lg p-4">
<div v-if="previewUrl" class="relative inline-block">
<img :src="previewUrl" class="max-w-full h-auto rounded" />
<button
@click="removeImage"
type="button"
class="absolute top-2 right-2 p-1 bg-red-500 text-white rounded-full hover:bg-red-600"
>
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
<div v-else>
<input
ref="fileInput"
type="file"
:accept="accept"
class="hidden"
@change="handleFileChange"
/>
<button
type="button"
@click="selectFile"
:disabled="isUploading"
class="w-full py-8 px-4 text-center text-gray-500 hover:text-gray-700 hover:bg-gray-50 rounded transition-colors disabled:opacity-50"
>
<svg
v-if="!isUploading"
class="mx-auto h-12 w-12 text-gray-400"
stroke="currentColor"
fill="none"
viewBox="0 0 48 48"
>
<path
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<div
v-else
class="mx-auto h-12 w-12 flex items-center justify-center"
>
<svg
class="animate-spin h-6 w-6 text-gray-400"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</div>
<p class="mt-1 text-sm">
{{ isUploading ? "上传中..." : "点击上传或拖拽文件到此处" }}
</p>
<p class="mt-1 text-xs text-gray-400">
{{ accept }} 文件,最大 {{ maxSize }}MB
</p>
</button>
</div>
</div>
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import { useFileUpload } from "@/composables/useFileUpload";
const props = defineProps<{
label?: string;
modelValue?: string | File;
accept?: string;
maxSize?: number;
}>();
const emit = defineEmits<{
"update:modelValue": [value: string | File | undefined];
}>();
const { isUploading, error } = useFileUpload();
const fileInput = ref<HTMLInputElement>();
const localFile = ref<File | null>(null);
const previewUrl = computed(() => {
if (localFile.value) {
return URL.createObjectURL(localFile.value);
}
if (typeof props.modelValue === "string") {
return props.modelValue;
}
return "";
});
const selectFile = () => {
fileInput.value?.click();
};
const handleFileChange = (e: Event) => {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
if (props.maxSize && file.size > props.maxSize * 1024 * 1024) {
error.value = `文件大小不能超过 ${props.maxSize}MB`;
return;
}
localFile.value = file;
emit("update:modelValue", file);
};
const removeImage = () => {
localFile.value = null;
emit("update:modelValue", undefined);
if (fileInput.value) {
fileInput.value.value = "";
}
};
</script>

路由守卫

src/router/index.ts

import { createRouter, createWebHistory } from "vue-router";
import { pb } from "@/api";
import type { RouteRecordRaw } from "vue-router";
const routes: RouteRecordRaw[] = [
{
path: "/",
name: "home",
component: () => import("@/views/Home.vue"),
},
{
path: "/login",
name: "login",
component: () => import("@/views/Login.vue"),
meta: { requiresGuest: true },
},
{
path: "/register",
name: "register",
component: () => import("@/views/Register.vue"),
meta: { requiresGuest: true },
},
{
path: "/dashboard",
name: "dashboard",
component: () => import("@/views/Dashboard.vue"),
meta: { requiresAuth: true },
},
{
path: "/posts",
name: "posts",
component: () => import("@/views/Posts.vue"),
},
{
path: "/posts/new",
name: "post-create",
component: () => import("@/views/PostEdit.vue"),
meta: { requiresAuth: true },
},
{
path: "/posts/:id",
name: "post-detail",
component: () => import("@/views/PostDetail.vue"),
},
{
path: "/posts/:id/edit",
name: "post-edit",
component: () => import("@/views/PostEdit.vue"),
meta: { requiresAuth: true },
},
{
path: "/:pathMatch(.*)*",
name: "not-found",
component: () => import("@/views/NotFound.vue"),
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
// 导航守卫
router.beforeEach((to, from, next) => {
const isAuthenticated = pb.authStore.isValid;
const requiresAuth = to.meta.requiresAuth;
const requiresGuest = to.meta.requiresGuest;
if (requiresAuth && !isAuthenticated) {
// 需要认证但未登录,跳转到登录页
next({
name: "login",
query: { redirect: to.fullPath },
});
} else if (requiresGuest && isAuthenticated) {
// 需要游客状态但已登录,跳转到首页
next({ name: "home" });
} else {
next();
}
});
export default router;

生产部署

1. 环境变量配置

确保在生产环境中设置正确的 PocketBase URL:

.env.production
VITE_PB_URL=https://api.yourserver.com

2. 构建应用

Terminal window
# 构建生产版本
npm run build
# 输出在 dist/ 目录

3. 部署选项

Vercel / Netlify

创建 vercel.jsonnetlify.toml

{
"buildCommand": "npm run build",
"outputDirectory": "dist",
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}

Nginx 配置示例

server {
listen 80;
server_name app.yourserver.com;
root /var/www/vue-pocketbase-app/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# 代理 PocketBase API
location /api/ {
proxy_pass http://localhost:8090/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
}

4. Docker 部署

创建 Dockerfile

# 构建阶段
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# 生产阶段
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

docker-compose.yml

version: "3.8"
services:
pocketbase:
image: spectmax/pocketbase:latest
ports:
- "8090:8090"
volumes:
- ./pb_data:/pb_data
- ./pb_public:/pb_public
restart: unless-stopped
vue-app:
build: .
ports:
- "80:80"
depends_on:
- pocketbase
restart: unless-stopped

总结

本教程涵盖了 Vue 3 与 PocketBase 集成的完整流程:

  1. 项目初始化 - 使用 Vite 创建 Vue 3 项目
  2. 认证系统 - 完整的用户注册、登录、状态管理
  3. CRUD 操作 - 文章的增删改查
  4. 实时功能 - WebSocket 实时订阅
  5. 文件上传 - 图片上传和预览
  6. 路由守卫 - 认证保护
  7. 生产部署 - 多种部署方案

通过这个教程,你应该能够构建一个功能完整的 Vue 3 + PocketBase 应用。

参考资源


相关文章