在魔法學院裡,並非每個房間都對所有人敞開。有的房間需要通行證(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)。
前端負責「看門」,後端負責「最後一道城門」。
從現在開始,你的飲料王國不再是一座無門的花園,而是一座秩序井然、禮制分明的魔法城市。接下來,我們將把這些結界與資料快取、錯誤顯示與重試結合,讓整體體驗更絲滑。