iT邦幫忙

2025 iThome 鐵人賽

DAY 14
0
Modern Web

Medusa.js 石化我的心系列 第 22

Day22 進階實作 - 自製儀表板

  • 分享至 

  • xImage
  •  

Medusa.js 除了提供完整的電商 API,也支援 Admin UI 自訂化。這讓我們能直接在後台加上自製的功能頁面,完全符合業務需求。

這篇文章我會示範如何在 Medusa Admin 中建立一個 客製化保健食品需求單管理頁面,並支援查看、編輯、回覆需求。

另外, MedusaJs 內建儀表板是使用 React 去建構的,所以本篇文章都會使用 React

為什麼要自製儀表板?

預設的 Medusa Admin 提供了商品、訂單、顧客、行銷等模組管理功能。但在實際業務裡,我們常常會有額外的需求,比如我目前需要:

  • 客製化營養補充品回覆!
  • 客製化營養補充品列表

這些需求如果沒有自制儀表板,管理員只能透過 API 操作,效率不高。
👉 有了自制儀表板,就能在 Medusa Admin 介面裡一鍵完成。

初始化 JS SDK

在所有自製元件到儀表板,是有可能與本身後端交流的,所以MedusaJS有一個JS SDK套件可以簡單地與API要資料。

初始化sdk需要以下參數:

  • baseUrl:MeudsaJS後端 URL
  • debug:是否啟用紀錄,通常開發時會選擇true,預設是 false
  • auth type:授權的方法,如果是 MedusaJS 儀表板選擇 session
import Medusa from "@medusajs/js-sdk"

export const sdk = new Medusa({
  baseUrl: import.meta.env.VITE_BACKEND_URL || "/",
  debug: import.meta.env.DEV,
  auth: {
    type: "session",
  },
})

新增元件或者新增頁面

  • 新增元件到既有的頁面步驟:
    1. 新增元件(.tsx)檔到src/widget/
    2. 運用 MeudsaJS UI或者其他到 .tsx 檔
    3. 選擇新增的地區,細節部分參考這裏``
  • 新增頁面到儀表板中。
    1. 先確認是否有製作相關的 API(接口)
    2. 將頁面歸類在src/routes/...
    3. 運用 MeudsaJS UI或者其他到 .tsx 檔

Medusa UI 套件

開發 Admin 自製 UI,MedusaJS UI套件非常好用:

  • @medusajs/ui
    • Medusa 官方 UI 元件庫,包含 DataTable、Drawer、Button、Input 等等。
    • 開發自訂頁面時非常方便,風格一致。

完整程式碼範例

以下是一個完整的頁面程式碼及註解,支援查看、編輯、儲存回覆:

// 匯入 Medusa Admin SDK 的 Route 設定方法
import { defineRouteConfig } from "@medusajs/admin-sdk"
// 匯入圖示
import { TagSolid, Eye } from "@medusajs/icons"
// 匯入 MedusaJS 提供的 UI 元件
import { 
  Container,
  Heading,
  createDataTableColumnHelper,
  DataTable,
  DataTablePaginationState,
  useDataTable,
  Button,
  Drawer,
  Label,
  Input,
} from "@medusajs/ui"

// 匯入 React Query 與 SDK
import { useQuery } from "@tanstack/react-query"
import { sdk } from "../../sdk"
import { useMemo, useState } from "react"

// 資料型別:每筆客製化保健食品需求
type Supplement = {
  id: string,
  requirement: string,   // 使用者需求
  sure: boolean,         // 是否確認
  created_at: string,    // 建立時間
  response: object       // 回覆內容(JSON 格式,含理由 & 成分)
}

// 後端回傳的資料結構
type SupplementsResponse = {
  supplements: Supplement[]
  count: number
  limit: number
  offset: number
}

// 建立 DataTable 欄位助手
const columnHelper = createDataTableColumnHelper<Supplement>()

const SupplementsPage = () => {
  // === 分頁設定 ===
  const limit = 15 // 每頁顯示 15 筆
  const [pagination, setPagination] = useState<DataTablePaginationState>({
    pageSize: limit,
    pageIndex: 0,
  })

  // offset 計算 (根據目前第幾頁)
  const offset = useMemo(() => pagination.pageIndex * limit, [pagination])

  // 使用 React Query 取得資料
  const { data, isLoading, refetch } = useQuery<SupplementsResponse>({
    queryFn: () =>
      sdk.client.fetch(`/admin/supplement/showSupplement`, {
        query: { limit, offset }, // 傳入分頁參數
      }),
    queryKey: [["supplement", limit, offset]], // cache key
  })

  // === Drawer 狀態控制 ===
  const [open, setOpen] = useState(false) // Drawer 是否開啟
  const [selected, setSelected] = useState<Supplement | null>(null) // 當前選中的需求單

  // === 分開控制「理由」與「成分」輸入框 ===
  const [reason, setReason] = useState("")
  const [formula, setFormula] = useState("")

  // === 儲存回覆(理由 + 成分) ===
  const handleSave = async () => {
    if (!selected) return

    try {
      await sdk.client.fetch(`/admin/supplement/createResponse`, {
        method: "POST",
        body: {
          id: selected.id,
          // 組合成 JSON 格式儲存
          response: {
            理由: reason,
            成分: formula,
          },
        },
      })

      // 成功後關閉 Drawer,清空輸入框,重新抓資料
      setOpen(false)
      setReason("")
      setFormula("")
      refetch()
    } catch (err) {
      console.error("更新失敗", err)
    }
  }

  // === 定義 DataTable 欄位 ===
  const columns = [
    columnHelper.accessor("id", { header: "ID" }),
    columnHelper.accessor("requirement", { header: "Requirement" }),
    columnHelper.accessor("created_at", { header: "Created At" }),
    columnHelper.accessor("sure", {
      header: "Sure",
      // true/false 轉換成 Yes/No
      cell: (info) => (info.getValue() ? "Yes" : "No"),
    }),
    // 自訂「操作」欄位
    columnHelper.display({
      id: "actions",
      header: "Actions",
      cell: ({ row }) => (
        <Button
          variant="secondary"
          size="small"
          onClick={() => {
            // 設定選中的 row
            setSelected(row.original)

            // 如果已有回覆資料,帶入「理由」與「成分」到輸入框
            if (row.original.response) {
              const res = row.original.response as any
              setReason(res?.理由 ?? "")
              setFormula(res?.成分 ?? "")
            } else {
              setReason("")
              setFormula("")
            }

            // 打開 Drawer
            setOpen(true)
          }}
        >
          <Eye className="w-4 h-4" />
          查看
        </Button>
      ),
    }),
  ]

  // === 初始化 DataTable ===
  const table = useDataTable({
    columns,
    data: data?.supplements || [],
    getRowId: (row) => row.id,
    rowCount: data?.count || 0,
    isLoading,
    pagination: { state: pagination, onPaginationChange: setPagination },
  })

  return (
    <Container className="divide-y p-0">
      {/* 主表格 */}
      <DataTable instance={table}>
        <DataTable.Toolbar>
          <Heading>客製化保健食品需求單</Heading>
        </DataTable.Toolbar>
        <DataTable.Table />
        <DataTable.Pagination />
      </DataTable>

      {/* Drawer:顯示選取需求單的詳細資訊 */}
      <Drawer open={open} onOpenChange={setOpen}>
        <Drawer.Content>
          <Drawer.Header>
            <Drawer.Title>需求單詳情</Drawer.Title>
            <Drawer.Description>
              查看或編輯 Supplement 資料
            </Drawer.Description>
          </Drawer.Header>

          <Drawer.Body className="flex flex-col gap-4">
            {/* 基本資訊 (唯讀) */}
            <div className="flex flex-col gap-2">
              <Label>ID</Label>
              <Input readOnly value={selected?.id ?? ""} />
            </div>
            <div className="flex flex-col gap-2">
              <Label>需求</Label>
              <Input readOnly value={selected?.requirement ?? ""} />
            </div>

            {/* 編輯區:理由 */}
            <div className="flex flex-col gap-2">
              <Label>理由</Label>
              <Input
                placeholder="輸入理由..."
                value={reason}
                onChange={(e) => setReason(e.target.value)}
              />
            </div>

            {/* 編輯區:成分 */}
            <div className="flex flex-col gap-2">
              <Label>成分</Label>
              <Input
                placeholder="輸入成分..."
                value={formula}
                onChange={(e) => setFormula(e.target.value)}
              />
            </div>
          </Drawer.Body>

          {/* Footer:操作按鈕 */}
          <Drawer.Footer>
            <Button variant="secondary" onClick={() => setOpen(false)}>
              關閉
            </Button>
            <Button variant="primary" onClick={handleSave}>
              儲存變更
            </Button>
          </Drawer.Footer>
        </Drawer.Content>
      </Drawer>
    </Container>
  )
}

// 註冊這個頁面在 Medusa Admin UI 的路由設定
export const config = defineRouteConfig({
  label: "Supplements", // 側邊欄顯示名稱
  icon: TagSolid,       // 側邊欄的 icon
})

export default SupplementsPage


小結

透過 Medusa Admin SDK@medusajs/ui,我們可以輕鬆打造符合需求的自製頁面:

  • DataTable 列出資料
  • Drawer 顯示詳細內容
  • Textarea 編輯並送出回覆

這樣一來,業務就能直接在 Medusa Admin 介面中處理需求單,完全不需要跳到 Postman 或其他 API 工具。


上一篇
Day21 API 端口測試
下一篇
Day23 進階實作 - 使用 Notification 與 Subscribers 觸發通知
系列文
Medusa.js 石化我的心24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言