在第 1–14 天,我們把「點餐術」練到能下單、驗證、統計,還會和伺服器對話。
但真正的產品,還需要清晰的入口與可分享的路徑——這就是 Vue Router 的舞台。
今天,我們把系統切分為三個「魔法場景」:
/order:使用者直達下單與列表。/summary:收單者快速總覽。/order/:id:客服/同事一鍵直達「某一筆」資料。把網址變成「可溝通的契約」:可直達(少點一步)、可重現(重整/隔天回來也在同一畫面)、可分享(貼給任何人都看同一筆)。
/order,不要再從首頁繞路。」/summary。」/order/:id 是唯一連結,雙方對話不迷路。| 需求 | 角色 | 目的 | 功能 | 使用時機 | 
|---|---|---|---|---|
| 以網址直達點餐畫面 | 一般使用者 | 省去從首頁點擊 | 路由 /order | 想直接開始點餐 | 
| 查看整體統計 | 組長/收單者 | 快速掌握數量 | 路由 /summary | 收單前確認總量 | 
| 分享某筆訂單 | 點餐者/客服 | 讓對方直接看到指定訂單 | 路由 /order/:id | 客服查單、對帳 | 
為什麼一定要有 /order/:id?
因為「唯一連結 = 唯一真相入口」。沒有它,只能叫對方自己在總表慢慢找,非常不友善。
通常我們會需要這功能就是直接把unique link貼給別人~這樣他就可以直接倒向那個頁面了
Ps. 大家應該有發現使用router前,網址都是固定不動的 原因就是這些頁面就是透過前端框架去render出來的他並非是一頁一頁的html舊的網站結構。
那麼我們為了要有特別的URL就會需要用到router的套件幫我們完成這件事情。
今天我們會講解到這些概念~
<router-link>:安全又優雅的頁內傳送門(避免整頁重新載入)。<router-view>:頁面容器,負責顯示對應的頁面元件。:id:像「卷軸編號」,可依參數載入不同內容(例 /order/123)。接下來就來一一解釋這些咚咚的作用吧~~
路由表(routes)
是什麼:把「網址路徑 ➜ 頁面元件」配對的清單(導航地圖)。
為什麼:讓 Router 知道不同 URL 該顯示哪個頁面。
在哪寫:src/router/index.js 的 createRouter({ routes })。
範例:
const routes = [
  { path: '/', redirect: '/order' },
  { path: '/order', component: OrderPage },
  { path: '/summary', component: SummaryPage },
]
<router-link>
是什麼:內建的超連結元件。
為什麼:SPA 內部跳轉不重整頁面(更快、更平滑)。
怎麼用:像 <a>,但用 to 指向路徑。
範例:
<router-link to="/order">點餐之塔</router-link>
<router-link :to="`/order/${id}`">查看詳情</router-link>
<router-view>
是什麼:頁面插槽,Router 會把匹配到的頁面元件渲染在這裡。
為什麼:你的頁面切換都在這個容器發生。
用法:通常寫在 App.vue 的版型區。
範例:
<template>
  <nav>…導航…</nav>
  <router-view /> <!-- 這裡會顯示對應的頁面 -->
</template>
動態路由 :id
是什麼:帶參數的路徑(像卷軸編號),可顯示不同內容。
為什麼:讓每筆資料都有專屬 URL(可分享/可重現)。
怎麼取值:在頁面裡用 useRoute().params.id。
範例:
// 定義
{ path: '/order/:id', component: OrderDetailPage }
// 使用(頁面內)
import { useRoute } from 'vue-router'
const route = useRoute()
const id = route.params.id
路由守衛(進階)
是什麼:進入/切換路由前的攔截器(像結界)。
為什麼:檢查登入、權限、或載入前置資料。
在哪寫:全域守衛 router.beforeEach,或單頁的 beforeRouteEnter(在 Vue Router 4 可用 Composition API 替代)。
範例(全域):
router.beforeEach((to, from, next) => {
  const isLoggedIn = Boolean(localStorage.getItem('token'))
  if (to.meta.requiresAuth && !isLoggedIn) {
    next('/login')
  } else {
    next()
  }
})
如果把它們放進一句話:
「routes 決定地圖、router-link 是傳送門、router-view 是登場舞台、:id 是卷軸編號、守衛 是入口結界。」
這是我們今天的時序圖流程畫面~
大家可以參考一下~

今天程式流程其實很簡單主要是做router的切割
還有page的建置~

新增 GET /api/orders/:id:
// backend/server.js (節錄)
app.get("/api/orders/:id", async (req, res) => {
  try {
    const orders = await readOrders();
    const order = orders.find(o => o.id === req.params.id);
    if (!order) return res.status(404).json({ error: "找不到指定的訂單" });
    res.json(order);
  } catch (error) {
    res.status(500).json({ error: "無法取得訂單" });
  }
});
package.json 已加入 vue-router
src/main.js 掛載 router
day15/frontend/src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
import { router } from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
src/router/index.js 定義 /order、/summary、/order/:id
App.vue 放全域導覽與 router-view
day15/frontend/src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import OrderPage from '../pages/OrderPage.vue'
import SummaryPage from '../pages/SummaryPage.vue'
import OrderDetailPage from '../pages/OrderDetailPage.vue'
const routes = [
  { path: '/', redirect: '/order' },
  { path: '/order', component: OrderPage },
  { path: '/summary', component: SummaryPage },
  { path: '/order/:id', component: OrderDetailPage },
]
export const router = createRouter({
  history: createWebHistory(),
  routes,
})
day15/frontend/src/App.vue
<script setup>
</script>
<template>
  <main class="page">
    <h1>飲料點單系統 (Router 版)</h1>
    <nav style="display:flex; gap:8px; margin:12px 0;">
      <router-link to="/order" class="btn">點餐之塔</router-link>
      <router-link to="/summary" class="btn">結算之室</router-link>
    </nav>
    <router-view />
  </main>
  
</template>
day15/frontend/src/pages/OrderPage.vue
<script setup>
import { onMounted } from 'vue'
import OrderForm from '../components/OrderForm.vue'
import OrderList from '../components/OrderList.vue'
import { useOrderStore } from '../stores/orderStore'
import { useMenuStore } from '../stores/menuStore'
const orderStore = useOrderStore()
const menuStore = useMenuStore()
onMounted(() => {
  orderStore.loadOrders()
  menuStore.loadMenu()
})
function handleSubmit(payload) {
  orderStore.createOrder(payload)
}
function handleEdit({ index, patch }) {
  orderStore.updateOrder(index, patch)
}
function handleRemove(index) {
  orderStore.removeOrder(index)
}
</script>
<template>
  <section>
    <div v-if="orderStore.error" class="error-message">
      ⚠️ {{ orderStore.error }}
      <button @click="orderStore.loadOrders" class="btn btn-sm">重新載入</button>
    </div>
    <div v-if="orderStore.loading" class="loading-message">
      🔄 載入中...
    </div>
    <OrderForm
      :disabled="orderStore.loading"
      :drinks="menuStore.drinks"
      :sweetnessOptions="menuStore.sweetnessOptions"
      :iceOptions="menuStore.iceOptions"
      :menuRules="menuStore.rules"
      @submit="handleSubmit"
    />
    <section class="list">
      <h3>目前已送出的訂單 ({{ orderStore.orders.length }} 筆)</h3>
      <OrderList :orders="orderStore.orders" @edit="handleEdit" @remove="handleRemove" />
    </section>
    <section class="block" style="margin-top:16px">
      <h3>祕書匯入訂單(貼上 JSON 陣列)</h3>
      <textarea
        :value="orderStore.ordersJson"
        @input="orderStore.setOrdersJson($event.target.value)"
        style="width:100%; min-height:160px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;"
        placeholder='[ { "name": "王小美", "note": "少冰", "drink": "紅茶", "sweetness": "去糖", "ice": "去冰" } ]'
      ></textarea>
      <div class="actions" style="margin-top:8px">
        <button class="btn primary" @click="orderStore.replaceAllOrders(JSON.parse(orderStore.ordersJson))">套用到後端</button>
        <button class="btn" @click="orderStore.loadOrders">重新載入(後端)</button>
      </div>
    </section>
  </section>
  
</template>

day15/frontend/src/pages/SummaryPage.vue
<script setup>
import { onMounted } from 'vue'
import { useOrderStore } from '../stores/orderStore'
import { useMenuStore } from '../stores/menuStore'
import OrderStats from '../components/OrderStats.vue'
const orderStore = useOrderStore()
const menuStore = useMenuStore()
onMounted(() => {
  if (!orderStore.orders.length) {
    orderStore.loadOrders()
  }
  if (!menuStore.drinks.length) {
    menuStore.loadMenu()
  }
})
</script>
<template>
  <section class="stats">
    <h2>結算之室</h2>
    <nav style="margin:8px 0">
      <router-link class="btn" to="/order">回到點餐</router-link>
    </nav>
    <OrderStats :orders="orderStore.orders" :summary="orderStore.summaryRows" />
  </section>
</template>

day15/frontend/src/pages/OrderDetailPage.vue
<script setup>
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { OrderService } from '../services/orderService'
const route = useRoute()
const router = useRouter()
const order = ref(null)
const loading = ref(false)
const error = ref('')
async function load() {
  loading.value = true
  error.value = ''
  try {
    order.value = await OrderService.getById(route.params.id)
  } catch (e) {
    error.value = '讀取訂單失敗'
  } finally {
    loading.value = false
  }
}
onMounted(load)
</script>
<template>
  <section class="block">
    <h2>訂單詳情</h2>
    <div v-if="loading" class="loading-message">讀取中...</div>
    <div v-else-if="error" class="error-message">{{ error }}</div>
    <template v-else-if="order">
      <p><b>姓名:</b>{{ order.name }}</p>
      <p><b>飲料:</b>{{ order.drink }}</p>
      <p><b>甜度:</b>{{ order.sweetness }}</p>
      <p><b>冰量:</b>{{ order.ice }}</p>
      <p><b>備註:</b>{{ order.note }}</p>
      <p><b>建立時間:</b>{{ order.createdAt }}</p>
      <p v-if="order.updatedAt"><b>更新時間:</b>{{ order.updatedAt }}</p>
      <div class="actions" style="margin-top:12px">
        <button class="btn" @click="router.push('/order')">返回列表</button>
      </div>
    </template>
    <div v-else>無資料</div>
  </section>
</template>

day15/frontend/src/components/OrderList.vue
<script setup>
import { reactive, ref } from 'vue'
const props = defineProps({ orders: { type: Array, required: true } })
const emit  = defineEmits(['edit', 'remove'])
const editIndex = ref(-1)
const editForm  = reactive({ name: '', note: '', drink: '', sweetness: '', ice: '' })
function toggleEdit(i){
  if (editIndex.value === i) { editIndex.value = -1; return }
  editIndex.value = i
  Object.assign(editForm, props.orders[i])
}
function applyEdit(){
  if (editIndex.value < 0) return
  emit('edit', { index: editIndex.value, patch: { ...editForm } })
  editIndex.value = -1
}
function cancelEdit(){ editIndex.value = -1 }
function removeOrder(i){
  emit('remove', i)
  if (editIndex.value === i) editIndex.value = -1
}
</script>
<template>
  <ul>
    <li v-for="(o, i) in props.orders" :key="i" class="order">
      <div class="row">
        <div class="col">
          <span class="idx">{{ i + 1 }}.</span>
          <span class="name">{{ o.name }}</span>
          <span class="pill">{{ o.drink }}</span>
          <span class="pill" :class="o.ice === '去冰' ? 'is-noice' : 'is-ice'">{{ o.ice }}</span>
          <span class="pill" :class="o.sweetness === '去糖' ? 'is-nosugar' : 'is-sugar'">{{ o.sweetness }}</span>
          <span v-if="o.note" class="note">備註:{{ o.note }}</span>
        </div>
        <div class="actions">
          <router-link v-if="o.id" class="btn btn-sm" :to="`/order/${o.id}`">詳情</router-link>
          <button class="btn btn-sm" @click="toggleEdit(i)">{{ editIndex === i ? '收合' : '編輯' }}</button>
          <button class="btn btn-sm del" @click="removeOrder(i)">刪除</button>
        </div>
      </div>
      <!-- 編輯區塊省略 -->
    </li>
  </ul>
</template>
import { http } from './http'
export const OrderService = {
  async list() {
    const { data } = await http.get('/api/orders')
    return data
  },
  async getById(id) {
    const { data } = await http.get(`/api/orders/${id}`)
    return data
  },
  async create(payload) {
    const { data } = await http.post('/api/orders', payload)
    return data
  },
  async update(id, patch) {
    const { data } = await http.put(`/api/orders/${id}`, patch)
    return data
  },
  async remove(id) {
    const { data } = await http.delete(`/api/orders/${id}`)
    return data
  },
  async replaceAll(orders) {
    const { data } = await http.put('/api/orders/bulk', orders)
    return data
  },
}
import express from "express";
import { promises as fs } from "fs";
import path from "path";
import { fileURLToPath } from "url";
import cors from "cors";
// 🔎 GET /api/orders/:id - 取得指定訂單
app.get("/api/orders/:id", async (req, res) => {
  try {
    const orders = await readOrders();
    const order = orders.find(o => o.id === req.params.id);
    if (!order) {
      return res.status(404).json({ error: "找不到指定的訂單" });
    }
    res.json(order);
  } catch (error) {
    res.status(500).json({ error: "無法取得訂單" });
  }
});
前端 OrderService 新增 getById:
// src/services/orderService.js (節錄)
async getById(id) {
  const { data } = await http.get(`/api/orders/${id}`)
  return data
}
id 要顯示提示與返回入口。/order/:id 到新分頁,確保仍能顯示(包含前置載入)。今天,我們讓「網址」成為可靠的傳送門:
/order 直達下單、/summary 快速結算、/order/:id 指向唯一真相。
從此之後,客服、對帳、協作,一條連結就搞定。明天,我們可以在這個基礎上加入守衛、查詢字串、或麵包屑,把傳送門魔法升級成一套完整的導航體系。