前言
本篇文章介紹如何在 PHP 環境中結合 Firebase Firestore,實作一個前端按鈕匯出抽獎資料為 CSV 檔案的功能。此功能適合需要將 Firestore 資料批次匯出,並提供給非技術人員下載的場景。讀者需具備基本 PHP 與 JavaScript 知識,並了解 Firebase Firestore 的基本操作。
Firebase 初始化與匿名登入
在前端使用 Firebase SDK,首先透過 initializeApp 初始化 Firebase,並使用匿名登入 (signInAnonymously) 取得存取 Firestore 的權限。這樣做可以避免在伺服器端直接操作 Firebase,減少安全風險。
const app = initializeApp(firebaseConfig);
const auth = getAuth(app);
await signInAnonymously(auth);
const db = getFirestore(app);
分頁抓取 Firestore 資料
Firestore 資料量可能很大,因此使用分頁查詢避免一次讀取過多資料造成效能問題。透過 orderBy("__name__") 與 startAfter 搭配 limit,逐頁取得資料直到抓完為止。
async function fetchAllEntriesPaged(db, pageSize = 500) {
const colRef = collection(db, ENTRY_COLLECTION_PATH);
let all = [];
let lastDoc = null;
while (true) {
let q = query(colRef, orderBy("__name__"), limit(pageSize));
if (lastDoc) q = query(colRef, orderBy("__name__"), startAfter(lastDoc), limit(pageSize));
const snap = await getDocs(q);
if (snap.empty) break;
snap.forEach(d => all.push({ id: d.id, ...d.data() }));
lastDoc = snap.docs[snap.docs.length - 1];
if (snap.size < pageSize) break;
}
return all;
}
CSV 格式處理與下載
匯出 CSV 需要將資料轉成符合格式的字串,並處理特殊字元(如逗號、雙引號、換行)。csvEscape 函式負責這部分。下載時利用 Blob 物件與 URL.createObjectURL 產生可下載的連結。
function csvEscape(val) {
if (val === null || val === undefined) return "";
const s = String(val);
if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
return s;
}
function downloadCSV(lines, filename) {
const csv = "\uFEFF" + lines.join("\r\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
時間戳記格式轉換
Firestore 的時間欄位格式特殊,可能是 Timestamp 物件或其他形式,透過 tsToISO 函式統一轉成 ISO 8601 字串,方便 CSV 讀取與後續處理。
function tsToISO(v) {
try {
if (!v) return "";
if (typeof v.toDate === "function") return v.toDate().toISOString();
if (typeof v.seconds === "number") return new Date(v.seconds * 1000).toISOString();
return String(v);
} catch {
return "";
}
}
實作匯出流程
按下「下載 CSV」按鈕後,觸發 runExport 函式,依序完成初始化、登入、資料分頁抓取、CSV 組合、下載檔案與狀態更新。過程中會禁用按鈕避免重複點擊,並顯示目前狀態。
async function runExport() {
$status.textContent = "";
$btn.disabled = true;
$btn.textContent = "匯出中...";
try {
const app = initializeApp(firebaseConfig);
const auth = getAuth(app);
await signInAnonymously(auth);
const db = getFirestore(app);
$status.textContent = "正在抓取資料...";
const rows = await fetchAllEntriesPaged(db, 500);
if (!rows.length) {
$status.textContent = "沒有任何資料可匯出。";
return;
}
const headers = ["docId", "name", "classroom", "teacher", "entryDate", "entryNumber", "isWinner", "drawTime", "drawnBy", "createdAt", "updatedAt"];
const lines = [];
lines.push(headers.map(csvEscape).join(","));
for (const r of rows) {
const line = [
r.id ?? "",
r.name ?? "",
r.classroom ?? "",
r.teacher ?? "",
r.entryDate ?? "",
r.entryNumber ?? "",
(r.isWinner === true) ? "true" : "false",
r.drawTime ?? "",
r.drawnBy ?? "",
tsToISO(r.createdAt),
tsToISO(r.updatedAt),
].map(csvEscape).join(",");
lines.push(line);
}
const filename = `christmas_raffle_entries_${getTodayDate()}.csv`;
downloadCSV(lines, filename);
$status.textContent = `匯出完成:共 ${rows.length} 筆`;
} catch (e) {
console.error(e);
$status.textContent = `匯出失敗:${e.message}`;
} finally {
$btn.disabled = false;
$btn.textContent = "⬇️ 下載 CSV";
}
}
實際應用與延伸
此實作適合用於管理後台快速匯出 Firestore 資料,方便非技術人員下載報表。未來可擴充支援更多資料欄位,或改用後端匯出以保護 Firebase 金鑰安全。也可以加入進度條或分頁匯出功能,提升使用體驗。
常見問題與注意事項
- Firebase 配置需正確填寫並允許匿名登入。
- Firestore 權限規則需設定允許讀取資料。
- 大量資料匯出時,注意瀏覽器記憶體限制與執行時間。
- CSV 格式需妥善處理特殊字元避免格式錯亂。
完整程式碼
<?php
function at_christmas_export_menu_page()
{
?>
<div class="wrap">
<h1>匯出抽獎資料 CSV</h1>
<p>
<button id="at-export-btn" class="button button-primary">⬇️ 下載 CSV</button>
<span id="at-export-status" style="margin-left:10px;"></span>
</p>
</div>
<script type="module">
import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-app.js";
import { getAuth, signInAnonymously } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-auth.js";
import {
getFirestore,
collection,
query,
getDocs,
orderBy,
limit,
startAfter
} from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
const firebaseConfig = {
apiKey: "YOUR_API_KEY",
authDomain: "YOUR_AUTH_DOMAIN",
projectId: "YOUR_PROJECT_ID",
storageBucket: "YOUR_STORAGE_BUCKET",
messagingSenderId: "YOUR_MESSAGING_SENDER_ID",
appId: "YOUR_APP_ID",
measurementId: "YOUR_MEASUREMENT_ID"
};
const projectId = firebaseConfig.projectId || "YOUR_PROJECT_ID";
const ENTRY_COLLECTION_PATH = `artifacts/${projectId}/public/data/christmas_raffle_entries`;
const $btn = document.getElementById("at-export-btn");
const $status = document.getElementById("at-export-status");
function getTodayDate() {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, "0");
const d = String(now.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
function csvEscape(val) {
if (val === null || val === undefined) return "";
const s = String(val);
if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
return s;
}
function tsToISO(v) {
try {
if (!v) return "";
if (typeof v.toDate === "function") return v.toDate().toISOString();
if (typeof v.seconds === "number") return new Date(v.seconds * 1000).toISOString();
return String(v);
} catch {
return "";
}
}
async function fetchAllEntriesPaged(db, pageSize = 500) {
const colRef = collection(db, ENTRY_COLLECTION_PATH);
let all = [];
let lastDoc = null;
while (true) {
let q = query(colRef, orderBy("__name__"), limit(pageSize));
if (lastDoc) q = query(colRef, orderBy("__name__"), startAfter(lastDoc), limit(pageSize));
const snap = await getDocs(q);
if (snap.empty) break;
snap.forEach(d => all.push({ id: d.id, ...d.data() }));
lastDoc = snap.docs[snap.docs.length - 1];
if (snap.size < pageSize) break;
}
return all;
}
function downloadCSV(lines, filename) {
const csv = "\uFEFF" + lines.join("\r\n"); // BOM for Excel
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
async function runExport() {
$status.textContent = "";
$btn.disabled = true;
$btn.textContent = "匯出中...";
try {
const app = initializeApp(firebaseConfig);
const auth = getAuth(app);
await signInAnonymously(auth);
const db = getFirestore(app);
$status.textContent = "正在抓取資料...";
const rows = await fetchAllEntriesPaged(db, 500);
if (!rows.length) {
$status.textContent = "沒有任何資料可匯出。";
return;
}
const headers = [
"docId",
"name",
"classroom",
"teacher",
"entryDate",
"entryNumber",
"isWinner",
"drawTime",
"drawnBy",
"createdAt",
"updatedAt"
];
const lines = [];
lines.push(headers.map(csvEscape).join(","));
for (const r of rows) {
const line = [
r.id ?? "",
r.name ?? "",
r.classroom ?? "",
r.teacher ?? "",
r.entryDate ?? "",
r.entryNumber ?? "",
(r.isWinner === true) ? "true" : "false",
r.drawTime ?? "",
r.drawnBy ?? "",
tsToISO(r.createdAt),
tsToISO(r.updatedAt),
].map(csvEscape).join(",");
lines.push(line);
}
const filename = `christmas_raffle_entries_${getTodayDate()}.csv`;
downloadCSV(lines, filename);
$status.textContent = `匯出完成:共 ${rows.length} 筆`;
} catch (e) {
console.error(e);
$status.textContent = `匯出失敗:${e.message}`;
} finally {
$btn.disabled = false;
$btn.textContent = "⬇️ 下載 CSV";
}
}
$btn.addEventListener("click", runExport);
</script>
<?php
}
?>