Vue 3 + PocketBase 完整集成教程
从零开始学习如何在 Vue 3 项目中集成 PocketBase,包含认证、CRUD 操作、实时功能、状态管理和生产部署的完整指南。
概述
本教程将指导你完成 Vue 3 与 PocketBase 的完整集成,涵盖从项目初始化到生产部署的全部流程。我们将使用 Vue 3 Composition API、Pinia 状态管理,以及 PocketBase JavaScript SDK。
技术栈
- Vue 3 - 渐进式 JavaScript 框架
- PocketBase - 开源后端解决方案
- Pinia - Vue 状态管理
- Vue Router - 路由管理
- TypeScript - 类型安全
- Vite - 构建工具
目录
项目初始化
1. 创建 Vue 项目
# 使用 Vite 创建 Vue 3 + TypeScript 项目npm create vite@latest vue-pocketbase-app -- --template vue-ts
cd vue-pocketbase-app
# 安装依赖npm install
# 安装 PocketBase SDKnpm install pocketbase
# 安装 Pinia 和 Vue Routernpm install pinia vue-router2. 项目结构
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:8090VITE_PB_URL_PROD=https://api.yourserver.com创建 .env.production 文件:
VITE_PB_URL=https://api.yourserver.com2. 初始化 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:
VITE_PB_URL=https://api.yourserver.com2. 构建应用
# 构建生产版本npm run build
# 输出在 dist/ 目录3. 部署选项
Vercel / Netlify
创建 vercel.json 或 netlify.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/htmlCOPY 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 集成的完整流程:
- 项目初始化 - 使用 Vite 创建 Vue 3 项目
- 认证系统 - 完整的用户注册、登录、状态管理
- CRUD 操作 - 文章的增删改查
- 实时功能 - WebSocket 实时订阅
- 文件上传 - 图片上传和预览
- 路由守卫 - 认证保护
- 生产部署 - 多种部署方案
通过这个教程,你应该能够构建一个功能完整的 Vue 3 + PocketBase 应用。