Google Firebase Studio - daniel-qa/Vue GitHub Wiki
先把需求壓到最精簡、但實用:
📝 發布文章(新增)
📚 文章列表(讀取)
✏️ 編輯文章(更新)
❌ 刪除文章(刪除)
👍 一個 Like 按鈕(互動)
🔐 只有登入者可以發文(Auth)
這就是典型 CRUD + 一點互動,Firebase 超擅長。
- 核心學習重點
Firebase 的優勢展示:
無需後端伺服器 - 前端直接連接 Firebase
即時同步 - serverTimestamp() 自動處理時間
原子操作 - increment() 避免競爭條件
安全規則 - 透過 Firebase Console 設定權限
擴展性 - 自動處理大量請求
實際應用場景:
✅ 使用者系統 (Auth)
✅ 內容管理 (Firestore CRUD)
✅ 社交互動 (Like 功能)
✅ 權限控制 (僅作者可編輯)
- Like 按鈕
Like 按鈕新增到 PostsPanel.vue:
❤️ 愛心圖示 + 讚數顯示
顯示在每篇文章的作者資訊旁邊 所有人都可以按讚(不需登入)
- 按讚邏輯
使用 increment(1) 自動增加 likeCount
點擊後立即更新 Firestore
自動重新載入文章列表顯示新讚數
- UI 調整
按鈕樣式: 灰色背景,圓角設計
作者資訊和 Like 按鈕並排顯示
編輯/刪除按鈕保持在右側
- 刪除
async function deletePost(postId, postTitle) {
msg.value = "";
if (!user.value) {
msg.value = "❌ 請先登入";
return;
}
const confirmed = confirm(`確定要刪除文章「${postTitle}」嗎?此操作無法復原。`);
if (!confirmed) {
return;
}
try {
const postRef = doc(db, "posts", postId);
await deleteDoc(postRef);
msg.value = "✅ 刪除成功";
if (editingId.value === postId) {
cancelEdit();
}
await loadPosts();
} catch (e) {
msg.value = `❌ 刪除失敗:${e.code || e.message}`;
}
}
- 刪除按鈕
紅色按鈕顯示在每篇文章的右下角(作者本人可見) 與「編輯」按鈕並排顯示
- 確認對話框
點擊刪除時會彈出確認視窗 顯示文章標題,提醒「此操作無法復原」 使用者可以取消操作
- 刪除邏輯
使用 deleteDoc 從 Firestore 刪除文件 如果正在編輯該文章,會自動取消編輯模式 刪除後自動重新載入文章列表
- 權限控制
只有文章作者本人可以看到刪除按鈕 需要登入才能執行刪除操作
c:\Firebase\firebase-blog-web\
├── src/
│ ├── firebase.js # Firebase 配置
│ ├── main.js # 應用入口 + auth 監聽
│ ├── App.vue # 主組件 (Auth + Posts)
│ └── components/
│ ├── AuthPanel.vue # 登入/註冊介面
│ └── PostsPanel.vue # 文章發布/列表
├── package.json
└── vite.config.js
- Firestore Security Rules(Firebase 安全規則語言)
用途:寫在 Firebase Console → Firestore Database → 規則,用來決定「誰可以對哪些文件做哪些操作」。
這段在講什麼(逐行拆解)
1) allow create / update / delete
allow create: if request.auth != null
&& request.resource.data.authorId == request.auth.uid;
create:新增文件(例如 addDoc()、setDoc() 新建)
update:更新既有文件(例如 updateDoc()、setDoc(..., {merge:true}))
delete:刪除文件(例如 deleteDoc())
allow create: if <條件>; 的意思就是:符合條件才允許 create
2) 這些關鍵字是什麼
request.auth => 代表「發出這個請求的人」的登入資訊
request.auth != null 就是 有登入(匿名也算登入)
resource.data 代表 資料庫裡原本就存在的那筆文件
update/delete 才有「原本的文件」可比對,所以常用它來驗證作者
request.resource.data 代表「這次請求想要寫進去的資料(寫入後會長這樣)」
create/update 常用它來檢查寫入內容是否合法(例如 authorId 不准亂填)
✅ Firestore Rules(CRUD 安全版:Create 需登入、Update/Delete 只限作者)
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /posts/{postId} {
// 文章列表公開可讀(想改成登入才可讀也行)
allow read: if true;
// ✅ 只有登入者能新增,且 authorId 必須等於自己的 uid
allow create: if request.auth != null
&& request.resource.data.authorId == request.auth.uid;
// ✅ 只有作者能更新
allow update: if request.auth != null
&& resource.data.authorId == request.auth.uid
// 防止把 authorId 偷改成別人
&& request.resource.data.authorId == resource.data.authorId;
// ✅ 只有作者能刪除
allow delete: if request.auth != null
&& resource.data.authorId == request.auth.uid;
}
// 其他集合一律拒絕(避免誤開)
match /{document=**} {
allow read, write: if false;
}
}
}
按 發布 / Publish。
立刻驗證(建議你一定要做)
A) 用同一個帳號(作者本人)
編輯自己的文章 → ✅ 成功
刪除自己的文章 → ✅ 成功
B) 用另一個帳號(非作者)
先登出
用另一個 Email 註冊/登入(例如 [email protected])
嘗試去編輯/刪除 test1 的文章(前端按鈕可能會 disabled,但你可以先把前端 disabled 拿掉或直接在 Console 用 REST 不方便;最簡單:先確認 Firestore Console 右側「刪除文件」在非作者身分是做不到的)
預期:permission-denied
你前端按鈕現在會 disabled(正常)
你目前 UI 版已經有:
:disabled="!user || p.authorId !== user.uid"
所以「非作者」看不到操作是合理的;真正的安全靠我們這份 rules。
我們先把 Firestore Rules 收到「只有登入者能新增(Create)」,其他(讀/改/刪)先暫時保持原本(測試模式的開放)讓你先跑功能不中斷。
你現在去這裡
Firebase Console → Firestore Database → 規則(Rules)
你會看到一段 rules。
把 rules 改成下面這份(只限制 Create 需登入)
直接整段覆蓋貼上,然後按「發布 / Publish」
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /posts/{postId} {
// 文章:所有人可讀(你要公開列表的話)
allow read: if true;
// ✅ 只有登入者能新增
allow create: if request.auth != null;
// 先暫時開放更新/刪除(下一步我們再收緊到只有作者)
allow update, delete: if true;
}
// 其他集合先全部拒絕(避免誤開)
match /{document=**} {
allow read, write: if false;
}
}
}
立刻驗證(超重要)
在前端 先登出 → 點「發布文章」
預期:顯示 ❌ 發文失敗:permission-denied(或類似)
再 登入 → 點「發布文章」
預期:✅ 發文成功
- Step 1:先在 Firebase Console 建立 Firestore
到 Firebase Console:
Build → Firestore Database → 建立資料庫
模式:先選 測試模式(之後再改規則)
地區:選你方便的
完成後你會看到 Firestore Database 的資料頁面。
1) 前端:新增 PostsPanel
A) 建檔 src/components/PostsPanel.vue
把下面整份貼進去:
<script setup>
import { ref, onMounted } from "vue";
import { auth, db } from "../firebase";
import { onAuthStateChanged } from "firebase/auth";
import {
addDoc,
collection,
query,
orderBy,
limit,
getDocs,
serverTimestamp,
} from "firebase/firestore";
const title = ref("");
const content = ref("");
const msg = ref("");
const posts = ref([]);
const user = ref(null);
onMounted(() => {
onAuthStateChanged(auth, (u) => {
user.value = u;
});
});
async function loadPosts() {
msg.value = "";
try {
const q = query(collection(db, "posts"), orderBy("createdAt", "desc"), limit(20));
const snap = await getDocs(q);
posts.value = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
} catch (e) {
msg.value = `❌ 讀取失敗:${e.code || e.message}`;
}
}
async function createPost() {
msg.value = "";
if (!user.value) {
msg.value = "❌ 請先登入才可以發文";
return;
}
if (!title.value.trim() || !content.value.trim()) {
msg.value = "❌ Title / Content 不能空白";
return;
}
try {
await addDoc(collection(db, "posts"), {
title: title.value.trim(),
content: content.value.trim(),
authorId: user.value.uid,
authorEmail: user.value.email,
createdAt: serverTimestamp(),
updatedAt: serverTimestamp(),
likeCount: 0,
});
title.value = "";
content.value = "";
msg.value = "✅ 發文成功";
await loadPosts();
} catch (e) {
msg.value = `❌ 發文失敗:${e.code || e.message}`;
}
}
onMounted(loadPosts);
</script>
<template>
<div style="max-width: 720px; margin: 24px auto; text-align: left;">
<h2>Posts</h2>
<div style="padding: 12px; border: 1px solid #ddd; border-radius: 8px;">
<div style="margin-bottom: 8px;">
<label>Title</label>
<input v-model="title" style="width: 100%; padding: 8px; margin-top: 6px;" />
</div>
<div style="margin-bottom: 8px;">
<label>Content</label>
<textarea v-model="content" rows="4" style="width: 100%; padding: 8px; margin-top: 6px;"></textarea>
</div>
<div style="display:flex; gap: 8px;">
<button @click="createPost">發布文章</button>
<button @click="loadPosts">重新載入</button>
</div>
<p style="margin-top: 10px; white-space: pre-wrap;">{{ msg }}</p>
</div>
<hr style="margin: 18px 0;" />
<div v-if="posts.length === 0">(目前沒有文章)</div>
<div v-for="p in posts" :key="p.id" style="padding: 12px 0; border-bottom: 1px solid #eee;">
<div style="font-weight: 700;">{{ p.title }}</div>
<div style="white-space: pre-wrap; margin: 6px 0;">{{ p.content }}</div>
<div style="font-size: 12px; color: #666;">
by {{ p.authorEmail || p.authorId }} · likeCount: {{ p.likeCount ?? 0 }}
</div>
</div>
</div>
</template>
B) 改 src/App.vue:同時顯示 Auth + Posts
把 App.vue 改成:
<script setup>
import AuthPanel from "./components/AuthPanel.vue";
import PostsPanel from "./components/PostsPanel.vue";
</script>
<template>
<AuthPanel />
<PostsPanel />
</template>
2) 驗證:發第一篇文章
你的 AuthPanel 先保持登入狀態(畫面顯示登入成功)
在 Posts 區塊輸入 Title/Content
點 發布文章
回 Firebase Console(你現在這頁)
左邊會自動出現集合 posts,點進去會看到一筆 doc ✅
1) 覆蓋 src/components/AuthPanel.vue(完整版)
把你現在那個極簡版整份換成下面這份:
<script setup>
import { ref } from "vue";
import { auth } from "../firebase";
import {
createUserWithEmailAndPassword,
signInWithEmailAndPassword,
signOut,
} from "firebase/auth";
const email = ref("");
const password = ref("");
const msg = ref("");
async function signup() {
msg.value = "";
try {
const cred = await createUserWithEmailAndPassword(auth, email.value, password.value);
msg.value = `✅ 註冊成功:${cred.user.email}`;
} catch (e) {
msg.value = `❌ 註冊失敗:${e.code || e.message}`;
}
}
async function login() {
msg.value = "";
try {
const cred = await signInWithEmailAndPassword(auth, email.value, password.value);
msg.value = `✅ 登入成功:${cred.user.email}`;
} catch (e) {
msg.value = `❌ 登入失敗:${e.code || e.message}`;
}
}
async function logout() {
msg.value = "";
try {
await signOut(auth);
msg.value = "✅ 已登出";
} catch (e) {
msg.value = `❌ 登出失敗:${e.code || e.message}`;
}
}
</script>
<template>
<div style="max-width: 420px; margin: 40px auto; text-align: left;">
<h2>Auth Demo</h2>
<label>Email</label>
<input
v-model="email"
type="email"
placeholder="[email protected]"
style="width: 100%; padding: 8px; margin: 6px 0 12px;"
/>
<label>Password</label>
<input
v-model="password"
type="password"
placeholder="至少 6 碼"
style="width: 100%; padding: 8px; margin: 6px 0 12px;"
/>
<div style="display: flex; gap: 8px;">
<button @click="signup">註冊</button>
<button @click="login">登入</button>
<button @click="logout">登出</button>
</div>
<p style="margin-top: 12px; white-space: pre-wrap;">{{ msg }}</p>
</div>
</template>
2) 測試(照這順序)
Email:[email protected]
Password:test1234(>= 6 碼)
點 註冊
點 登出
點 登入
同時看 Console(你之前加的 onAuthStateChanged):
登入成功會印出 uid
登出會回到 signed out
我們用最穩的方式一步步確認,照做一定會出現登入畫面:
1) 先確認檔案結構(很關鍵)
你的專案要長這樣:
src/App.vue
src/main.js
src/components/AuthPanel.vue
(components 資料夾沒有就自己建)
2) 直接把 src/App.vue 整份覆蓋成這樣
先不要管原本 Vite 預設內容,直接換掉
<script setup>
import AuthPanel from "./components/AuthPanel.vue";
</script>
<template>
<AuthPanel />
</template>
3) 建 src/components/AuthPanel.vue(最小可跑版)
先用這個極簡版,確保元件能顯示:
<template>
<div style="max-width:420px;margin:40px auto;text-align:left">
<h2>Auth Demo</h2>
<p>如果你看到這行,代表 AuthPanel 已掛上首頁 ✅</p>
</div>
</template>
4) 回到瀏覽器,按一下重新整理
你應該立刻看到 Auth Demo 這行。
如果還是 Vite 預設畫面:
99% 是你改到「不是同一個專案資料夾」,或 Vite dev server 不是跑這個資料夾。
這時請你回到 cmd,確認你 npm run dev 是在 C:\Firebase\firebase-blog-web 執行的。
5) 確認極簡版 OK 後,再把 AuthPanel 換成完整版(註冊/登入)
我再把完整版貼一次你直接覆蓋即可(含註冊/登入/登出)。
- Step 1:新增元件 src/components/AuthPanel.vue
把我前一則提供的 AuthPanel.vue 建起來(含註冊/登入/登出那段)。
- Step 2:把首頁換成 AuthPanel(改 src/App.vue)
把 src/App.vue 改成:
<script setup>
import AuthPanel from "./components/AuthPanel.vue";
</script>
<template>
<AuthPanel />
</template>
然後回到瀏覽器刷新
你就會在首頁看到 Email/Password 輸入框 + 三個按鈕(註冊/登入/登出)。
接著用:
Email:[email protected]
Password:test1234
按 註冊 就能完成第一個功能。
你 Console 已經出現 auth state: signed out,代表:
Vite/Vue 專案 OK
Firebase SDK 初始化 OK
Auth 也已經可用(至少能讀到狀態)
接下來我們進入第一個「可用功能」:註冊 / 登入 / 登出(Email/Password)
(先把 Auth 打通,後面才鎖「只有登入者可發文」。)
- 1 ) 先確認 Console 已開 Email/Password
Firebase Console → Build → Authentication → Sign-in method
✅ Email/Password 要是 Enabled
(如果你不確定也沒關係,你照做下面程式,錯誤訊息會直接告訴我們。)
- 1. 前端先能連上 Firebase,然後 開 Auth + Firestore。
把 Firebase 接進來,然後用 Console 看到 signed out 代表連線成功。
下一步:把 Firebase 接進來,然後用 Console 看到 signed out 代表連線成功。
1) 建 src/firebase.js
在專案建立檔案 src/firebase.js,貼這段:
import { initializeApp } from "firebase/app";
import { getAuth } from "firebase/auth";
import { getFirestore } from "firebase/firestore";
const firebaseConfig = {
apiKey: "AIzaSyBr4SSowFxfOxbPK5tvY0XwtU6xYODA4BM",
authDomain: "fir-blog-lab.firebaseapp.com",
projectId: "fir-blog-lab",
storageBucket: "fir-blog-lab.firebasestorage.app",
messagingSenderId: "994110275058",
appId: "1:994110275058:web:1b7c165f56d810c3b0ff6b",
};
export const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);
export const db = getFirestore(app);
2) 在 src/main.js 加監聽(驗證用)
打開 src/main.js,在 createApp(App).mount('#app') 前後都可以,加上:
import { onAuthStateChanged } from "firebase/auth";
import { auth } from "./firebase";
onAuthStateChanged(auth, (user) => {
console.log("auth state:", user ? user.uid : "signed out");
});
3) Firebase Console 也要先開服務(否則下一步做登入會卡)
到 Firebase Console:
Build → Authentication → Sign-in method → 開啟 Email/Password
Build → Firestore Database → 建立資料庫(先測試模式)
4) 驗證結果
回到你這個頁面(127.0.0.1:58589),按 F12 → Console
你應該會看到:
auth state: signed out
看到這行就完成「前端成功接上 Firebase」。
- 初始化
npm i firebase
npm run dev
- Firebase Console 入口
https://console.firebase.google.com/
https://magecomp.com/support/knowledgebase/firebase-console/?utm_source=chatgpt.com
很多人以為有個叫「Firebase Studio」的獨立工具,
但實際上你真正使用的是 Firebase Console + SDK + Hosting 這一整套雲端開發環境。
npm install firebase
// Import the functions you need from the SDKs you need
import { initializeApp } from "firebase/app";
import { getAnalytics } from "firebase/analytics";
// TODO: Add SDKs for Firebase products that you want to use
// https://firebase.google.com/docs/web/setup#available-libraries
// Your web app's Firebase configuration
// For Firebase JS SDK v7.20.0 and later, measurementId is optional
const firebaseConfig = {
apiKey: "AIzaSyBS6cEGosMNMHD8ai9XK7nFljAhSc5Oo2U",
authDomain: "myfire-8ab17.firebaseapp.com",
projectId: "myfire-8ab17",
storageBucket: "myfire-8ab17.firebasestorage.app",
messagingSenderId: "164224360045",
appId: "1:164224360045:web:6e6384cc2ae018bfd7e964",
measurementId: "G-PRYK9MGN8P"
};
// Initialize Firebase
const app = initializeApp(firebaseConfig);
const analytics = getAnalytics(app);