儀表板

網路商店的管理後台。切換期間後,數字卡片和圖表會變成該期間的資料。

儀表板

重點

  1. 1

    數字從原始資料計算出來

    營業額、訂單數等總計,用 reduce 把每天的資料加起來得到。切換期間後,只要重新計算就會對上。

  2. 2

    圖表交給函式庫來畫

    長條、刻度,以及滑鼠移上去時顯示的數字,都由 Recharts 繪製。我們只需要告訴它「要畫哪份資料的哪個欄位」。

  3. 3

    用顏色和符號表示增加或減少

    和上一個期間相比,增加就顯示綠色的 ▲,減少就顯示紅色的 ▼。不用讀數字,一眼就看得出好壞。

使用的工具

點擊查看第 2 部分的比較

修改程式碼

修改程式碼,右邊的預覽會跟著變化。

import { useState } from "react"
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
import { Button } from "./components/ui/button"

// 每天的營業額和訂單數(實際專案中從伺服器取得)。這裡把今天當作 10/4
const weekDays = [
  { day: "9/28", sales: 30300, orders: 38 },
  { day: "9/29", sales: 25500, orders: 33 },
  { day: "9/30", sales: 28200, orders: 36 },
  { day: "10/1", sales: 35100, orders: 44 },
  { day: "10/2", sales: 19500, orders: 27 },
  { day: "10/3", sales: 42900, orders: 61 },
  { day: "10/4", sales: 47100, orders: 64 },
]

// 近 30 天(9/5~10/4)= 9/5~9/27 的 23 天 + 上面的近 7 天(同一天的數字保持一致)
// 這 23 天用固定公式產生(僅供示範,不用記)
const monthDays = [
  ...Array.from({ length: 23 }, (_, i) => ({
    day: `9/${i + 5}`,
    sales: 21900 + ((i * 37) % 11) * 2400 + (i % 7 <= 1 ? 14100 : 0),
    orders: 30 + ((i * 37) % 11) * 3 + (i % 7 <= 1 ? 18 : 0),
  })),
  ...weekDays,
]

// 各期間的資料。previous 是「上一個期間」的數字(用來比較增減)
const periods = {
  week: { label: "近 7 天", days: weekDays, members: 48, previous: { sales: 204000, orders: 286, average: 720, members: 40 } },
  month: { label: "近 30 天", days: monthDays, members: 186, previous: { sales: 990000, orders: 1390, average: 720, members: 201 } },
}

// 最近的訂單
const recentOrders = [
  { id: "A-1052", name: "陳怡君", amount: 2550, status: "已出貨" },
  { id: "A-1051", name: "林志明", amount: 900, status: "備貨中" },
  { id: "A-1050", name: "王雅婷", amount: 4650, status: "已出貨" },
  { id: "A-1049", name: "張家豪", amount: 600, status: "備貨中" },
  { id: "A-1048", name: "李佳穎", amount: 1500, status: "已出貨" },
]

// 金額格式(30300 → NT$30,300)
function yen(value) {
  return "NT$" + value.toLocaleString()
}

// 與上一期相比增加了百分之幾(減少時為負數)。例:本期 120、上期 100 → 20
function changePercent(value, previous) {
  return Math.round(((value - previous) / previous) * 100)
}

export default function App() {
  // ① 目前選取的期間("week" = 近 7 天,"month" = 近 30 天),存在 state 中
  //const period = "week"
  const [period, setPeriod] = useState("week")

  const current = periods[period] // 所選期間的資料

  // ② 期間內的總計
  //const sales = 1144000 // 每天營業額(d.sales)的總和
  const sales = current.days.reduce((sum, d) => sum + d.sales, 0)
  //const orders = 303 // 每天訂單數(d.orders)的總和
  const orders = current.days.reduce((sum, d) => sum + d.orders, 0)
  const average = Math.round(sales / orders) // 客單價 = 營業額 ÷ 訂單數

  // 4 張數字卡片的資料(label = 名稱,text = 大字顯示的內容,change = 增加的百分比)
  const stats = [
    { label: "營業額", text: yen(sales), change: changePercent(sales, current.previous.sales) },
    { label: "訂單數", text: orders.toLocaleString() + " 筆", change: changePercent(orders, current.previous.orders) },
    { label: "客單價", text: yen(average), change: changePercent(average, current.previous.average) },
    { label: "新會員", text: current.members + " 人", change: changePercent(current.members, current.previous.members) },
  ]

  return (
    <div className="mx-auto my-6 flex w-full max-w-4xl flex-col gap-6 px-4 text-foreground">
      {/* 標題與期間按鈕 */}
      <div className="flex flex-wrap items-center justify-between gap-3">
        <h1 className="text-xl font-semibold">儀表板</h1>
        <div className="flex gap-2">
          {/* ③ 點擊切換期間。選取的為實心(default),另一個為外框(outline) */}
          <Button size="sm" variant={period === "week" ? "default" : "outline"} onClick={() => setPeriod("week")}>
            近 7 天
          </Button>
          <Button size="sm" variant={period === "month" ? "default" : "outline"} onClick={() => setPeriod("month")}>
            近 30 天
          </Button>
        </div>
      </div>

      {/* ④ 數字卡片:用 map 列出 4 個 stats */}

      <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
        {stats.map((stat) => (
          <div key={stat.label} className="rounded-xl border bg-card p-4 text-card-foreground">
            <p className="text-xs text-muted-foreground">{stat.label}</p>
            <p className="mt-1 text-xl font-semibold tabular-nums">{stat.text}</p>
            {/* ⑤ 增加(change 大於等於 0)顯示綠色 ▲,減少顯示紅色 ▼ */}
            {/* <p className="mt-1 text-xs text-emerald-600"> */}
            <p className={stat.change >= 0 ? "mt-1 text-xs text-emerald-600" : "mt-1 text-xs text-red-600"}>
              {stat.change >= 0 ? "▲" : "▼"} {Math.abs(stat.change)}%
              <span className="text-muted-foreground"> 與上期相比</span>
            </p>
          </div>
        ))}
      </div>


      {/* 營業額圖表(Recharts 會把傳給 data 的清單畫成長條圖) */}
      <div className="rounded-xl border bg-card p-4 text-card-foreground">
        <p className="text-sm font-medium">營業額趨勢</p>
        <div className="mt-4 h-56">
          <ResponsiveContainer width="100%" height="100%">
            <BarChart data={current.days}>
              <CartesianGrid vertical={false} stroke="var(--border)" />
              <XAxis dataKey="day" tickLine={false} axisLine={false} fontSize={12} />
              <YAxis tickLine={false} axisLine={false} fontSize={12} width={40} tickFormatter={(v) => v / 10000 + "萬"} />
              <Tooltip formatter={(v) => [yen(v), "營業額"]} cursor={{ fill: "var(--muted)" }} />
              <Bar dataKey="sales" fill="var(--primary)" radius={[4, 4, 0, 0]} />
            </BarChart>
          </ResponsiveContainer>
        </div>
      </div>

      {/* 最近的訂單 */}
      <div className="rounded-xl border bg-card p-4 text-card-foreground">
        <p className="text-sm font-medium">最近的訂單</p>
        <ul className="mt-3 divide-y text-sm">
          {recentOrders.map((order) => (
            <li key={order.id} className="flex items-center gap-3 py-2.5">
              <span className="w-16 text-muted-foreground tabular-nums">{order.id}</span>
              <span className="flex-1">{order.name}</span>
              <span className="tabular-nums">{yen(order.amount)}</span>
              <span
                // ⑥ 「已出貨」顯示綠色,其他(備貨中)顯示黃色
                className={order.status === "已出貨" ? "w-16 rounded-full bg-emerald-100 py-0.5 text-center text-xs text-emerald-700" : "w-16 rounded-full bg-amber-100 py-0.5 text-center text-xs text-amber-700"}
              >
                {order.status}
              </span>
            </li>
          ))}
        </ul>
      </div>
    </div >
  )
}