使用 Firebase Firestore 匯出抽獎資料為 CSV 檔案的實作說明

前言

本篇文章介紹如何在 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
}
?>