iT邦幫忙

2025 iThome 鐵人賽

DAY 26
0
Modern Web

用 LINE OA 打造中小企業訂單系統:從零開始的 30 天實作紀錄系列 第 26

讓管理者一眼掌握訂單全貌:訂單明細頁 / 後台 Dashboard 優化

  • 分享至 

  • xImage
  •  

昨天我們實作了雙向通知,讓顧客與群組都能即時收到訂單更新。今天要回到管理端的世界,打造一個「能查、能看、能篩」的訂單明細頁!

這一篇,我們將實作一個能顯示完整訂單資訊、支援搜尋與篩選的後台 Dashboard,讓店家管理更輕鬆。


目標

  • 建立後端 API:查詢單筆訂單明細。

  • 後台 Dashboard 顯示完整資料(商品、取貨方式、顧客資訊等)。

  • 增加搜尋、篩選、分頁功能,提升使用體驗。


一、後端 API:查詢訂單明細

我們在 src/routes/order.js 中新增一個端點:

// GET /orders/:id
router.get('/:id', async (req, res) => {
  try {
    const order = await Order.findById(req.params.id)
      .populate('userId', 'name lineUserId') // 關聯使用者資料
      .lean();

    if (!order) return res.status(404).json({ message: 'Order not found' });

    res.json(order);
  } catch (err) {
    console.error(err);
    res.status(500).json({ message: 'Server Error' });
  }
});

populate('userId') 可以將 MongoDB 中的 user 資料一併帶出,例如顧客姓名或 LINE ID,方便管理者識別。


二、前端 Dashboard:整合查詢與明細

假設我們使用前端框架(如 React / Next.js)打造後台,這裡示範如何在列表頁加入「查看明細」按鈕:

// pages/Dashboard.jsx
import { useEffect, useMemo, useRef, useState } from "react";
import axios from "axios";

const API_BASE = import.meta?.env?.VITE_API_BASE ?? "http://localhost:3000";

export default function Dashboard() {
  const [orders, setOrders] = useState([]);
  const [total, setTotal] = useState(0);

  // UI 狀態
  const [status, setStatus] = useState("");      // "", "Pending", "In Progress", "Completed"
  const [q, setQ] = useState("");                // 關鍵字(商品/顧客/訂單編號,依後端支援)
  const [page, setPage] = useState(1);
  const [limit, setLimit] = useState(10);
  const [loading, setLoading] = useState(false);
  const [errMsg, setErrMsg] = useState("");

  const token = useMemo(() => localStorage.getItem("jwt"), []);
  const totalPages = Math.max(1, Math.ceil(total / limit));
  const controllerRef = useRef(null);

  async function fetchOrders() {
    // 中止上一個請求避免 race condition
    if (controllerRef.current) controllerRef.current.abort();
    const controller = new AbortController();
    controllerRef.current = controller;

    setLoading(true);
    setErrMsg("");

    try {
      const res = await axios.get(`${API_BASE}/order`, {
        headers: { Authorization: `Bearer ${token}` },
        params: { status, q, page, limit },
        signal: controller.signal,
      });
      setOrders(res.data.orders ?? []);
      setTotal(res.data.total ?? 0);
    } catch (err) {
      if (axios.isCancel(err)) return;
      const detail =
        err?.response?.data?.message ||
        err?.message ||
        "載入失敗,請稍後再試";
      setErrMsg(detail);
    } finally {
      setLoading(false);
    }
  }

  useEffect(() => {
    fetchOrders();
    return () => controllerRef.current?.abort();
    // 依賴查詢條件
  }, [status, q, page, limit]);

  // 樂觀更新:先改 UI,失敗回滾
  async function updateStatus(id, newStatus) {
    const prev = orders;
    const next = orders.map((o) =>
      o._id === id ? { ...o, status: newStatus, _optimistic: true } : o
    );
    setOrders(next);

    try {
      await axios.patch(
        `${API_BASE}/order/${id}/status`,
        { status: newStatus },
        { headers: { Authorization: `Bearer ${token}` } }
      );
      // 成功後移除 _optimistic 標記(重新 fetch 也可)
      setOrders((cur) =>
        cur.map((o) =>
          o._id === id ? { ...o, _optimistic: false } : o
        )
      );
    } catch (err) {
      setOrders(prev); // 回滾
      const msg =
        err?.response?.data?.message || "更新失敗,請稍後再試";
      alert(msg);
    }
  }

  function onSearchSubmit(e) {
    e.preventDefault();
    setPage(1);
    fetchOrders();
  }

  return (
    <div style={{ padding: 24, maxWidth: 1080, margin: "0 auto" }}>
      <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 16 }}>
        訂單管理
      </h1>

      {/* 篩選列 */}
      <form
        onSubmit={onSearchSubmit}
        style={{ display: "flex", gap: 12, alignItems: "center", marginBottom: 16 }}
      >
        <select
          value={status}
          onChange={(e) => {
            setStatus(e.target.value);
            setPage(1);
          }}
        >
          <option value="">全部狀態</option>
          <option value="Pending">Pending</option>
          <option value="In Progress">In Progress</option>
          <option value="Completed">Completed</option>
        </select>

        <input
          type="text"
          placeholder="搜尋關鍵字(商品/顧客/訂單編號)"
          value={q}
          onChange={(e) => setQ(e.target.value)}
          style={{ flex: 1 }}
        />

        <button type="submit">搜尋</button>

        <select
          value={limit}
          onChange={(e) => {
            setLimit(Number(e.target.value));
            setPage(1);
          }}
        >
          <option value={10}>10 /頁</option>
          <option value={20}>20 /頁</option>
          <option value={50}>50 /頁</option>
        </select>
      </form>

      {/* Loading / Error */}
      {loading && <div>載入中…</div>}
      {!loading && errMsg && (
        <div style={{ color: "crimson", marginBottom: 12 }}>{errMsg}</div>
      )}

      {/* 列表 */}
      {!loading && !errMsg && (
        <>
          <table
            style={{
              width: "100%",
              borderCollapse: "collapse",
              border: "1px solid #eee",
            }}
          >
            <thead style={{ background: "#f7f7f7" }}>
              <tr>
                <th style={th}>訂單編號</th>
                <th style={th}>商品</th>
                <th style={th}>狀態</th>
                <th style={th}>操作</th>
              </tr>
            </thead>
            <tbody>
              {orders.length === 0 && (
                <tr>
                  <td colSpan={4} style={{ textAlign: "center", padding: 16 }}>
                    目前沒有符合條件的訂單
                  </td>
                </tr>
              )}
              {orders.map((o) => (
                <tr key={o._id}>
                  <td style={td} title={o._id}>
                    {o._id}
                  </td>
                  <td style={td}>
                    {Array.isArray(o.items)
                      ? o.items.map((i) => i.productName).join(", ")
                      : "-"}
                  </td>
                  <td style={td}>
                    <StatusBadge status={o.status} optimistic={o._optimistic} />
                  </td>
                  <td style={td}>
                    <select
                      value={o.status}
                      onChange={(e) => updateStatus(o._id, e.target.value)}
                    >
                      <option value="Pending">Pending</option>
                      <option value="In Progress">In Progress</option>
                      <option value="Completed">Completed</option>
                    </select>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>

          {/* 分頁 */}
          <div style={{ display: "flex", gap: 8, marginTop: 12, alignItems: "center" }}>
            <button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
              上一頁
            </button>
            <span>
              {page} / {totalPages}
            </span>
            <button
              disabled={page >= totalPages}
              onClick={() => setPage((p) => p + 1)}
            >
              下一頁
            </button>
            <span style={{ marginLeft: "auto", color: "#666" }}>
              共 {total} 筆
            </span>
          </div>
        </>
      )}
    </div>
  );
}

function StatusBadge({ status, optimistic }) {
  const color =
    status === "Completed"
      ? "#18a058"
      : status === "In Progress"
      ? "#2f54eb"
      : "#d89614";
  const style = {
    display: "inline-block",
    padding: "2px 8px",
    borderRadius: 999,
    background: "#fafafa",
    border: `1px solid ${color}`,
    color,
    fontSize: 12,
    fontWeight: 600,
    opacity: optimistic ? 0.6 : 1,
  };
  return <span style={style}>{status}{optimistic ? "(更新中)" : ""}</span>;
}

const th = { textAlign: "left", padding: 8, borderBottom: "1px solid #eee" };
const td = { padding: 8, borderBottom: "1px solid #f2f2f2" };

https://ithelp.ithome.com.tw/upload/images/20251010/20178868egilH3cyo6.png

三、搜尋、篩選與分頁設計

1️. 後端 API 優化查詢參數

// GET /orders?status=Completed&page=1&limit=10
const escapeRegExp = (s = "") => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");

router.get("/", authAdmin, async (req, res) => {
  try {
    const redis = await ensureConnected();
   
	// 1) 參數與防呆
    const {
      status = "", // 'Pending' | 'In Progress' | 'Completed' | ''
      q = "", // 關鍵字:比對 items.productName 或訂單 id
      pickup = "", // '自取' | '外送' | '宅配' | ''
      page: pageRaw = "1",
      limit: limitRaw = "10",
    } = req.query;

    const page = Math.max(1, parseInt(pageRaw, 10) || 1);
    const limit = Math.min(100, Math.max(1, parseInt(limitRaw, 10) || 10));
    const skip = (page - 1) * limit;

    // 2) 組 filter(依 order schema)
    const filter = {};
    if (status) filter.status = status;
    if (pickup) filter.pickup = pickup;
    if (q) {
      const rx = new RegExp(escapeRegExp(q), "i");
      // q 可能是 24 位 ObjectId:讓使用者可以直接貼訂單編號
      const isObjectId = /^[0-9a-fA-F]{24}$/.test(q);
      const byId = isObjectId ? { _id: q } : null;
      // 商品在 items 陣列的子文件,欄位是 items.productName
      // Mongo 可直接用「items.productName」+ regex,不一定要 $elemMatch
      const byProduct = { "items.productName": { $regex: rx } };
      filter.$or = [byId, byProduct].filter(Boolean);
    }
    // 3) 動態快取 Key(帶入條件,避免串到別人的結果)
    const KEY =
      "orders:list:v3:" + JSON.stringify({ status, pickup, q, page, limit });

    // 4) 先讀快取
    const cached = await redis.get(KEY);
    if (cached) {
      const data = JSON.parse(cached);
      return res.json({ success: true, source: "cache", ...data });
    }

    // 5) 查詢 + 計數(populate 只帶需要欄位)
    const [orders, total] = await Promise.all([
      Order.find(filter)
        .sort({ createdAt: -1 })
        .skip(skip)
        .limit(limit)
        .populate("userId", "name lineUserId") // 只帶名稱與 lineUserId
        .select({
          userId: 1,
          items: 1, // items: [{ productName, quantity, price }]
          status: 1,
          pickup: 1,
          createdAt: 1,
          updatedAt: 1,
        })
        .lean(),
      Order.countDocuments(filter),
    ]);

    const payload = { orders, total, page, limit };

    // 6) 寫入快取(短 TTL,後台不宜快取太久)
    await redis.set(KEY, JSON.stringify(payload), { EX: 30 });

    return res.json({ success: true, source: "db", ...payload });
  } catch (err) {
    console.error("[GET /order] error:", err);
    return res.status(500).json({ success: false, message: "Server Error" });
  }
});

2️. 前端加上搜尋列與分頁控制

<div className="flex mb-4">
  <select onChange={(e) => setStatus(e.target.value)}>
    <option value="">全部狀態</option>
    <option value="Pending">Pending</option>
    <option value="In Progress">In Progress</option>
    <option value="Completed">Completed</option>
  </select>
  <button onClick={loadOrders} className="ml-2">搜尋</button>
</div>

這樣就能快速切換不同狀態的訂單,方便管理者追蹤進度。


四、UI / UX 優化

  • Loading 狀態:用 spinner 或 skeleton 告訴使用者資料載入中。

  • 空狀態提示:當搜尋不到資料時顯示「目前沒有符合條件的訂單」。

  • 狀態顏色標籤:Completed = 綠色、Pending = 黃色、Failed = 紅色。

  • 固定表頭 + 滾動表格:提升可讀性。


重點回顧

  • 學會如何使用 populate() 將訂單與使用者關聯查詢。

  • 完成可篩選、分頁、查詢的後台 Dashboard。

  • 從開發者角度,理解「資料展示」與「操作便利性」的平衡。


明天(Day 27)我們將進入金流整合第一階段,介紹台灣常見的金流 API(LINE Pay、藍新、綠界),並帶你實際跑通沙箱付款流程!


上一篇
群組與顧客雙向通知設計:一次滿足兩邊需求的智慧推播
下一篇
打通付款關鍵的一步:金流串接初探(以綠界 Sandbox 為例
系列文
用 LINE OA 打造中小企業訂單系統:從零開始的 30 天實作紀錄30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言