在魔法學院裡,並非每個房間都對所有人敞開。有的房間需要通行證(token),有的房間只對長老(admin)開放。今天,我們在 Day15 的「傳送門術(Vue Router)」之上,施加結界(路由守衛)與身份驗證(登入):
今天會學會的魔法:發放簡易 token、前後端共同驗證、保護路由 /summary、保護單筆訂單 /order/:id。
| 需求 | 角色 | 目的 | 功能 | 使用時機 | 
|---|---|---|---|---|
| 登入取得 token 與角色 | 所有使用者 | 進入系統 | POST /api/login | 進入首頁前 | 
| 查看統計頁 | admin | 掌握整體 | 前端路由守衛 + /summary | 登入後 | 
| 檢視單筆訂單 | 下單者 / admin | 查詢或對帳 | 後端驗證 token 屬主或 admin | 進入 /order/:id | 
預設規則:
roni是 admin;其他帳號為 user。不存在的帳號會自動建立(密碼固定123456),登入後回傳{ username, role, token }。
這是我們新的登入功能的流程
我們會模擬一個token登入功能
塞到localstorage


前端
authStore:保管 { username, role, token }
後端
user.json、order.json)base64(username) 當示範 token(簡化教學;實務建議 JWT)GET /api/orders/:id 進行身份授權:本人或 admin 才可觀看backend/user.json
POST /api/login:密碼必須 123456;不存在就自動建立;回傳 { username, role, token }(roni 為 admin)GET /api/orders/:id:需 Authorization: Bearer <token>;僅本人或 admin 可取day16/backend/user.json
[
  {
    "username": "roni",
    "password": "123456",
    "role": "admin",
    "token": "cm9uaQ=="
  },
  {
    "username": "kevin",
    "password": "123456",
    "role": "user",
    "token": "a2V2aW4="
  }
]
day16/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";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = 3000;
const DATA_FILE = path.join(__dirname, "order.json");
const MENU_FILE = path.join(__dirname, "ordermenu.json");
const USER_FILE = path.join(__dirname, "user.json");
app.use(cors());
app.use(express.json());
async function readUsers() {
  try {
    const txt = await fs.readFile(USER_FILE, "utf8");
    if (!txt) return [];
    return JSON.parse(txt);
  } catch (error) { return []; }
}
async function writeUsers(users) {
  await fs.writeFile(USER_FILE, JSON.stringify(users, null, 2), "utf8");
}
function encodeToken(username){ return Buffer.from(username,'utf8').toString('base64') }
function decodeToken(token){ try{ return Buffer.from(token,'base64').toString('utf8') }catch{ return '' } }
// 登入(密碼需為 123456)
app.post('/api/login', async (req, res) => {
  try {
    const username = (req.body?.username || '').trim();
    const password = (req.body?.password || '').trim();
    if (!username) return res.status(400).json({ error: 'username 必填' });
    if (password !== '123456') return res.status(401).json({ error: '密碼錯誤' });
    let users = await readUsers();
    let user = users.find(u => u.username === username);
    const role = username === 'roni' ? 'admin' : 'user';
    if (!user) {
      user = { username, password: '123456', role, token: encodeToken(username) };
      users.push(user);
      await writeUsers(users);
    } else {
      if (user.password !== '123456') return res.status(401).json({ error: '密碼錯誤' });
      user.token = encodeToken(username);
      user.role = role;
      await writeUsers(users);
    }
    res.json({ username: user.username, role: user.role, token: user.token });
  } catch (e) { res.status(500).json({ error: '登入失敗' }); }
});
// 單筆訂單授權:本人或 admin
app.get("/api/orders/:id", async (req, res) => {
  try {
    const auth = req.headers['authorization'] || '';
    const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
    if (!token) return res.status(401).json({ error: '未提供授權' });
    const users = await readUsers();
    const username = decodeToken(token);
    const me = users.find(u => u.username === username && u.token === token);
    if (!me) return res.status(401).json({ error: '授權無效' });
    // ...讀取 orders 並檢查屬主或 admin(略,見原始碼)
  } catch (e) { res.status(500).json({ error: '無法取得訂單' }); }
});
stores/authStore.js:保存 { username, role, token } + localStorage;提供 isAuthenticated、isAdmin
pages/LoginPage.vue:呼叫 /api/login,成功後存入 store → 導向 /order
router/index.js:新增 /login;全域守衛:未登入導向 /login;/summary 需 adminservices/orderService.js:getById(id, token) 附 Authorization headerpages/OrderDetailPage.vue:從 store 取 token,呼叫 OrderService.getById
App.vue:新增「登出」按鈕,auth.clear() + 轉 /login
import { defineStore } from 'pinia'
const STORAGE_KEY = 'authState'
function loadState() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY)
    return raw ? JSON.parse(raw) : { username: '', role: '', token: '' }
  } catch {
    return { username: '', role: '', token: '' }
  }
}
export const useAuthStore = defineStore('auth', {
  state: () => ({ ...loadState() }),
  getters: {
    isAuthenticated: (s) => !!s.token,
    isAdmin: (s) => s.role === 'admin',
  },
  actions: {
    setAuth(payload) {
      this.username = payload.username
      this.role = payload.role
      this.token = payload.token
      localStorage.setItem(STORAGE_KEY, JSON.stringify({ username: this.username, role: this.role, token: this.token }))
    },
    clear() {
      this.username = ''
      this.role = ''
      this.token = ''
      try {
        localStorage.clear()
      } catch {}
    }
  }
})
day16/frontend/src/pages/LoginPage.vue
<script setup>
import { ref } from 'vue'
import { http } from '../services/http'
import { useAuthStore } from '../stores/authStore'
import { useRouter } from 'vue-router'
const username = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
const auth = useAuthStore()
const router = useRouter()
async function login() {
  error.value = ''
  loading.value = true
  try {
    const { data } = await http.post('/api/login', { username: username.value, password: password.value || '123456' })
    auth.setAuth(data)
    router.push('/order')
  } catch (e) {
    error.value = e.response?.data?.error || '登入失敗'
  } finally {
    loading.value = false
  }
}
</script>
<template>
  <main class="page">
    <h2>登入</h2>
    <div v-if="error" class="error-message">{{ error }}</div>
    <div class="block" style="max-width:360px">
      <label>使用者名稱:<input v-model.trim="username" placeholder="例如 corgi / roni" /></label>
      <label style="display:block; margin-top:8px">密碼:<input v-model.trim="password" type="password" placeholder="預設 123456" /></label>
      <div class="actions" style="margin-top:8px">
        <button class="btn primary" :disabled="loading || !username" @click="login">登入</button>
      </div>
      <p style="font-size:12px;color:#666;margin-top:8px">說明:若此使用者不存在將自動建立(密碼固定 123456)。使用者 roni 具備 admin 權限。</p>
    </div>
  </main>
</template>
day16/frontend/src/router/index.js
import LoginPage from '../pages/LoginPage.vue'
import { useAuthStore } from '../stores/authStore'
router.beforeEach((to) => {
  const auth = useAuthStore()
  if (to.path !== '/login' && !auth.isAuthenticated) return { path: '/login', query: { redirect: to.fullPath } }
  if (to.meta?.requiresAdmin && !auth.isAdmin) return { path: '/order' }
})
day16/frontend/src/App.vue
<script setup>
import { useAuthStore } from './stores/authStore'
import { useRouter } from 'vue-router'
const auth = useAuthStore()
const router = useRouter()
function logout() {
  auth.clear()
  router.push('/login')
}
</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>
      <span style="flex:1"></span>
      <button v-if="auth.isAuthenticated" class="btn" @click="logout">登出</button>
    </nav>
    <router-view />
  </main>
  
</template>
day16/frontend/src/services/orderService.js
async getById(id, token) {
  return (await http.get(`/api/orders/${id}`, { headers: { Authorization: `Bearer ${token}` } })).data
}
const auth = useAuthStore()
order.value = await OrderService.getById(route.params.id, auth.token)
base64(username) 當 token 容易理解;日後可替換成 JWT + 期限。App.vue 置頂導覽 + 登出;任一頁都能快速回城。後端
backend/server.js:新增 /api/login、加上單筆詳情授權backend/user.json:使用者資料存放前端
src/pages/LoginPage.vue:登入流程src/stores/authStore.js:登入狀態管理、持久化、清除src/router/index.js:全域守衛、/summary 僅 adminsrc/services/orderService.js:帶 Token 取單筆詳情src/pages/OrderDetailPage.vue:從 store 取 token 呼叫 APIsrc/App.vue:登出按鈕與導回 /login
/order 或 /summary → 被導向 /login
roni 登入可進入 /summary;其他帳號不可/order/:id;admin 可看全部


今天,我們把系統外圍加上了「結界」:通行證(token)+身份(role)+守衛(guard)。
前端負責「看門」,後端負責「最後一道城門」。
從現在開始,你的飲料王國不再是一座無門的花園,而是一座秩序井然、禮制分明的魔法城市。接下來,我們將把這些結界與資料快取、錯誤顯示與重試結合,讓整體體驗更絲滑。