iT邦幫忙

2025 iThome 鐵人賽

DAY 21
0
Vue.js

需求至上的 Vue 魔法之旅系列 第 21

Day 15.5 : 傳送門進階術 - 用 Vue Router 讓頁面與連結說話

  • 分享至 

  • xImage
  •  

前言

在第 1–14 天,我們把「點餐術」練到能下單、驗證、統計,還會和伺服器對話。
但真正的產品,還需要清晰的入口可分享的路徑——這就是 Vue Router 的舞台。

今天,我們把系統切分為三個「魔法場景」:

  • 點餐之塔 /order:使用者直達下單與列表。
  • 結算之室 /summary:收單者快速總覽。
  • 訂單詳情 /order/:id:客服/同事一鍵直達「某一筆」資料。

把網址變成「可溝通的契約」:可直達(少點一步)、可重現(重整/隔天回來也在同一畫面)、可分享(貼給任何人都看同一筆)。


一、User Story(以需求驅動傳送門設計)

敘事版

  • 角色:一般使用者
    「我想直接開始點,給我 /order,不要再從首頁繞路。」
  • 角色:組長/收單者
    「我要對帳,不想看一堆細節,只要總表 /summary。」
  • 角色:客服/協作同事
    「請把這一筆訂單丟給我看。」→ /order/:id 是唯一連結,雙方對話不迷路。

表格版

需求 角色 目的 功能 使用時機
以網址直達點餐畫面 一般使用者 省去從首頁點擊 路由 /order 想直接開始點餐
查看整體統計 組長/收單者 快速掌握數量 路由 /summary 收單前確認總量
分享某筆訂單 點餐者/客服 讓對方直接看到指定訂單 路由 /order/:id 客服查單、對帳

為什麼一定要有 /order/:id
因為「唯一連結 = 唯一真相入口」。沒有它,只能叫對方自己在總表慢慢找,非常不友善。

通常我們會需要這功能就是直接把unique link貼給別人~這樣他就可以直接倒向那個頁面了

Ps. 大家應該有發現使用router前,網址都是固定不動的 原因就是這些頁面就是透過前端框架去render出來的他並非是一頁一頁的html舊的網站結構。

那麼我們為了要有特別的URL就會需要用到router的套件幫我們完成這件事情。


二、Vue Router 的概念與技術(魔法小抄)

今天我們會講解到這些概念~

  • 路由表(routes):把「路徑」對應到「頁面元件」的地圖。
  • <router-link>:安全又優雅的頁內傳送門(避免整頁重新載入)。
  • <router-view>:頁面容器,負責顯示對應的頁面元件。
  • 動態路由 :id:像「卷軸編號」,可依參數載入不同內容(例 /order/123)。
  • 路由守衛(進階):像結界,可檢查登入/權限再決定是否放行。

接下來就來一一解釋這些咚咚的作用吧~~/images/emoticon/emoticon05.gif

  • 路由表(routes)

    • 是什麼:把「網址路徑 ➜ 頁面元件」配對的清單(導航地圖)。

    • 為什麼:讓 Router 知道不同 URL 該顯示哪個頁面。

    • 在哪寫src/router/index.jscreateRouter({ 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 是卷軸編號、守衛 是入口結界。」


三、導航與資料載入(時序 & 流程)

1) 時序圖:從網址到頁面

這是我們今天的時序圖流程畫面~

大家可以參考一下~

https://ithelp.ithome.com.tw/upload/images/20251005/20121052LEoTwcmCEE.png

2) 流程圖:匹配、守衛、顯示

今天程式流程其實很簡單主要是做router的切割

還有page的建置~

https://ithelp.ithome.com.tw/upload/images/20251005/2012105299x7QpB84K.png


3)為什麼要這樣切頁?

  • 職責分離:頁面 vs. 元件;路由掌控場景切換,元件專注可重用 UI
  • 可分享與重現:URL 即狀態入口,對內溝通/對外分享都直覺。
  • 擴充簡單:未來加上登入守衛、查詢字串、麵包屑導航…都能沿用結構。

四、實作步驟與完整程式碼

後端 API 新增 (unique 訂單的api)

新增 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: "無法取得訂單" });
  }
});

實作步驟與完整程式碼

  1. 安裝並啟用 Router
  • 前端 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')
  1. 建立路由表與頁面
  • 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>
  1. 點餐頁(OrderPage)
  • 負責表單、列表與匯入(統計表已抽離)

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>

https://ithelp.ithome.com.tw/upload/images/20251005/20121052vEXRyRsRyI.png

  1. 結算頁(SummaryPage)

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>

https://ithelp.ithome.com.tw/upload/images/20251005/20121052D5pULYALW0.png

  1. 訂單詳情頁(OrderDetailPage)
  • 動態路由讀取單筆訂單

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>

https://ithelp.ithome.com.tw/upload/images/20251005/20121052Rm8MSFqbDT.png

  1. 列表加入詳情連結

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>
  1. Service 新增單筆查詢
    day15/frontend/src/services/orderService.js
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
  },
}
  1. 後端 API 新增單筆查詢與小修
    backend/server.js
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
}

結語

注意事項(實戰提示)

  • 快取/避免重複撈:跨頁面時先判斷 store 是否已有資料(你已在頁面 onMounted 做到)。
  • 錯誤/載入 UI 一致:各頁都要能優雅顯示 loading / error。
  • 404/防呆:無此 id 要顯示提示與返回入口。
  • 可分享性測試:直接複製 /order/:id 到新分頁,確保仍能顯示(包含前置載入)。

今天,我們讓「網址」成為可靠的傳送門
/order 直達下單、/summary 快速結算、/order/:id 指向唯一真相。
從此之後,客服、對帳、協作,一條連結就搞定。明天,我們可以在這個基礎上加入守衛查詢字串、或麵包屑,把傳送門魔法升級成一套完整的導航體系。

day15 code


上一篇
Day 15 : 傳送門法陣 Vue Router 的冒險之門
下一篇
Day 16 : 結界與通行證:登入、權限與路由守衛
系列文
需求至上的 Vue 魔法之旅24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言