相信到這個地步,我們身為厲害的魔法師
一定會需要與各國的魔法師交流~這時候就需要多國語言的處理~!!
今天我們把語言切換這門魔法真正施展起來!
在小型專案裡,直接把文案寫死在前端就夠用了;但當飲料口味一路從紅茶、抹茶、巧克力長到「一整張菜單」、還要支援多國旅人時,把文案與資料分離就成了必修咒語。於是本章我們同時準備兩把魔杖:
src/locales/*.json
,版本可控、好維護。i18n.json
與 API 管理,Admin 可線上編輯,前端即時套用,不用重新發版。此外,我們會讓使用者的語系偏好跟著登入狀態同步回後端,下次進來自動還原。整套完成後,你會擁有:可分享、可維護、可擴充的多語系體驗,既符合工程實務,也符合魔法學院對「優雅」的標準
類別 | 檔案/動作 | 目的 |
---|---|---|
安裝 | npm i vue-i18n |
啟用前端 i18n 能力 |
新增(前端) | src/locales/zh-TW.json 、en-US.json 、ja-JP.json |
靜態 UI 文案(標題、按鈕、欄位、提示) |
新增(前端) | src/stores/i18nStore.js |
管理後端字典與偏好語系(Pinia) |
新增(前端) | src/pages/AdminI18nPage.vue |
Admin 編輯動態字典頁 |
修改(前端) | src/main.js |
掛載 i18n、載入靜態語系 |
修改(前端) | src/App.vue |
語系切換器、標題 $t 、同步偏好 |
修改(前端) | src/router/index.js |
/admin/i18n 路由 + 守衛 + afterEach 設定標題 |
修改(前端) | src/pages/OrderPage.vue |
選項使用「字典翻譯 label / value 原鍵」 |
修改(前端) | src/components/OptionGroup.vue |
支援 { value, label } |
修改(前端) | src/components/OrderForm.vue 、OrderList.vue 、OrderStats.vue 、OrderDetailPage.vue 、LoginPage.vue |
文案改 $t (本文節錄關鍵) |
新增(後端) | backend/i18n.json |
動態字典檔(可被 API 讀寫) |
修改(後端) | backend/server.js |
新增 /api/i18n-config 、/api/users/me 、/api/users/me/locale |
角色 | 需求 | 目的 | 路徑/功能 |
---|---|---|---|
外國魔法師 | 介面要能切換英文/日文 | 看得懂 UI | 前端 i18n(靜態 JSON) |
祕書/管理者(roni) | 想線上編輯「飲料/甜度/冰量」翻譯 | 不發版也能修改資料翻譯 | /admin/i18n + 後端 /api/i18n-config |
一般使用者 | 切換語系後下次還是同語系 | 保留偏好語言 | /api/users/me/locale |
訂單使用者/客服 | 列表/詳情/統計畫面顯示翻譯 | 觀感一致 | 以字典翻譯顯示,value 保留原始鍵 |
vue-i18n:Vue 官方 i18n 套件。
$t('key')
:取字串locale.value = 'en-US'
:切換語系Pinia:集中管理 i18n 字典狀態與 API 沟通(i18nStore
)。
兩種翻譯來源
設計準則:UI 文案(像「送出」、「登入」)放前端;商品字典(紅茶/綠茶…)放後端,方便營運修改。
我們來看一下今天的運作流程
cd day17/frontend
npm i vue-i18n
src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
import { router } from './router'
import { createI18n } from 'vue-i18n'
import zhTW from './locales/zh-TW.json'
import enUS from './locales/en-US.json'
import jaJP from './locales/ja-JP.json'
const app = createApp(App)
app.use(createPinia())
app.use(router)
const i18n = createI18n({
legacy: false,
locale: 'zh-TW',
fallbackLocale: 'en-US',
messages: { 'zh-TW': zhTW, 'en-US': enUS, 'ja-JP': jaJP }
})
app.use(i18n)
app.mount('#app')
這三支是前端介面固定文案翻譯檔,放
src/locales
。
src/locales/zh-TW.json
{
"app": { "title": "飲料點單系統 (Router 版)", "header": "飲料點單系統 (Router 版)" },
"nav": { "order": "點餐之塔", "summary": "結算之室" },
"pages": {
"login": "登入",
"loginHint": "說明:若此使用者不存在將自動建立(密碼固定 123456)。使用者 roni 具備 admin 權限。",
"summary": "結算之室",
"orderDetail": "訂單詳情",
"currentOrders": "目前已送出的訂單",
"bulkImport": "祕書匯入訂單(貼上 JSON 陣列)",
"stats": "統計結果"
},
"fields": {
"name": "姓名",
"note": "備註",
"drink": "飲料",
"sweetness": "甜度",
"ice": "冰量",
"count": "數量",
"totalCups": "總杯數",
"createdAt": "建立時間",
"updatedAt": "更新時間",
"username": "使用者名稱",
"password": "密碼"
},
"steps": {
"pickDrink": "步驟 1:選擇飲料",
"pickSweetness": "步驟 2:選擇甜度",
"pickIce": "步驟 3:選擇冰量"
},
"actions": {
"reload": "重新載入",
"applyToBackend": "套用到後端",
"reloadFromBackend": "重新載入(後端)",
"backToOrder": "回到點餐",
"submit": "送出",
"login": "登入",
"detail": "詳情",
"edit": "編輯",
"collapse": "收合",
"delete": "刪除",
"save": "儲存",
"cancel": "取消"
},
"placeholders": {
"name": "請輸入你的名字",
"note": "例如:三點拿、少冰",
"username": "例如 corgi / roni",
"password": "預設 123456"
},
"common": { "loading": "載入中...", "required": "必填", "optional": "選填" },
"validations": {
"pickSweetness": "請選擇甜度",
"noSugarForMatcha": "抹茶拿鐵不可去糖",
"pickIce": "請選擇冰量",
"chocolateHotOnly": "巧克力僅能熱飲",
"nameRequired": "姓名必填",
"nameMin": "至少 2 個字",
"nameMax": "最多 20 個字",
"noteMax": "備註最多 50 個字",
"noteBlacklist": "備註含禁用詞",
"pickDrink": "請選擇飲料",
"menuRuleMismatch": "選項與菜單規則不符",
"dailyLimit": "同一位使用者今日最多 3 杯",
"businessHours": "非營業時間(08:00–22:00)不可送單"
}
}
src/locales/en-US.json
{
"app": { "title": "Drink Ordering System (Router)", "header": "Drink Ordering System (Router)" },
"nav": { "order": "Order Tower", "summary": "Settlement Room" },
"pages": {
"login": "Login",
"loginHint": "If user does not exist, it will be created (password 123456). User roni has admin role.",
"summary": "Settlement Room",
"orderDetail": "Order Detail",
"currentOrders": "Submitted Orders",
"bulkImport": "Secretary Import (paste JSON array)",
"stats": "Statistics"
},
"fields": {
"name": "Name",
"note": "Note",
"drink": "Drink",
"sweetness": "Sweetness",
"ice": "Ice",
"count": "Count",
"totalCups": "Total Cups",
"createdAt": "Created At",
"updatedAt": "Updated At",
"username": "Username",
"password": "Password"
},
"steps": {
"pickDrink": "Step 1: Choose Drink",
"pickSweetness": "Step 2: Choose Sweetness",
"pickIce": "Step 3: Choose Ice"
},
"actions": {
"reload": "Reload",
"applyToBackend": "Apply to Backend",
"reloadFromBackend": "Reload (Backend)",
"backToOrder": "Back to Order",
"submit": "Submit",
"login": "Login",
"detail": "Detail",
"edit": "Edit",
"collapse": "Collapse",
"delete": "Delete",
"save": "Save",
"cancel": "Cancel"
},
"placeholders": {
"name": "Enter your name",
"note": "e.g. pick at 3pm, less ice",
"username": "e.g. corgi / roni",
"password": "default 123456"
},
"common": { "loading": "Loading...", "required": "Required", "optional": "Optional" },
"validations": {
"pickSweetness": "Please choose sweetness",
"noSugarForMatcha": "Matcha Latte cannot be sugar-free",
"pickIce": "Please choose ice",
"chocolateHotOnly": "Chocolate is hot only",
"nameRequired": "Name is required",
"nameMin": "At least 2 characters",
"nameMax": "At most 20 characters",
"noteMax": "Note at most 50 characters",
"noteBlacklist": "Note contains forbidden terms",
"pickDrink": "Please choose drink",
"menuRuleMismatch": "Selection violates menu rules",
"dailyLimit": "Max 3 cups per person per day",
"businessHours": "Ordering not allowed outside 08:00–22:00"
}
}
src/locales/ja-JP.json
{
"app": { "title": "ドリンク注文システム(ルーター版)", "header": "ドリンク注文システム(ルーター版)" },
"nav": { "order": "注文の塔", "summary": "精算の間" },
"pages": {
"login": "ログイン",
"loginHint": "ユーザーが存在しない場合は作成されます(パスワード 123456)。roni は管理者です。",
"summary": "精算の間",
"orderDetail": "注文詳細",
"currentOrders": "送信済みの注文",
"bulkImport": "秘書インポート(JSON 配列を貼り付け)",
"stats": "統計"
},
"fields": {
"name": "名前",
"note": "備考",
"drink": "ドリンク",
"sweetness": "甘さ",
"ice": "氷",
"count": "数量",
"totalCups": "合計杯数",
"createdAt": "作成日時",
"updatedAt": "更新日時",
"username": "ユーザー名",
"password": "パスワード"
},
"steps": {
"pickDrink": "ステップ1:ドリンクを選ぶ",
"pickSweetness": "ステップ2:甘さを選ぶ",
"pickIce": "ステップ3:氷を選ぶ"
},
"actions": {
"reload": "再読み込み",
"applyToBackend": "バックエンドに適用",
"reloadFromBackend": "(バックエンド)再読み込み",
"backToOrder": "注文に戻る",
"submit": "送信",
"login": "ログイン",
"detail": "詳細",
"edit": "編集",
"collapse": "折りたたむ",
"delete": "削除",
"save": "保存",
"cancel": "キャンセル"
},
"placeholders": {
"name": "お名前を入力してください",
"note": "例:15時受け取り、氷少なめ",
"username": "例:corgi / roni",
"password": "デフォルト 123456"
},
"common": { "loading": "読み込み中...", "required": "必須", "optional": "任意" },
"validations": {
"pickSweetness": "甘さを選択してください",
"noSugarForMatcha": "抹茶ラテは無糖不可",
"pickIce": "氷を選択してください",
"chocolateHotOnly": "チョコレートはホットのみ",
"nameRequired": "名前は必須です",
"nameMin": "2文字以上",
"nameMax": "20文字以内",
"noteMax": "備考は最大50文字",
"noteBlacklist": "備考に禁止語があります",
"pickDrink": "ドリンクを選択してください",
"menuRuleMismatch": "選択がメニュールールに違反しています",
"dailyLimit": "1日一人3杯まで",
"businessHours": "営業時間(08:00–22:00)外は注文不可"
}
}
src/stores/i18nStore.js
(負責動態字典 + 偏好語系)
import { defineStore } from 'pinia'
import { http } from '../services/http'
export const useI18nStore = defineStore('i18n', {
state: () => ({
languages: ['zh-TW','en-US','ja-JP'],
dict: { drinks: {}, sweetness: {}, ice: {} },
preferredLocale: 'zh-TW',
loading: false,
error: ''
}),
actions: {
async loadServerConfig() {
this.loading = true
this.error = ''
try {
const { data } = await http.get('/api/i18n-config')
this.languages = data.languages || this.languages
this.dict = { drinks: data.drinks || {}, sweetness: data.sweetness || {}, ice: data.ice || {} }
} catch (e) {
this.error = '載入 i18n 設定失敗'
} finally {
this.loading = false
}
},
async saveServerConfig(payload, token) {
this.loading = true
this.error = ''
try {
const { data } = await http.put('/api/i18n-config', payload, { headers: { Authorization: `Bearer ${token}` } })
this.languages = data.languages
this.dict = { drinks: data.drinks, sweetness: data.sweetness, ice: data.ice }
} catch (e) {
this.error = e.response?.data?.error || '更新 i18n 失敗'
} finally {
this.loading = false
}
},
translate(category, key, locale) {
const table = this.dict[category] || {}
const row = table[key]
if (!row) return key
return row[locale] || key
},
async loadMe(token) {
try {
const { data } = await http.get('/api/users/me', { headers: { Authorization: `Bearer ${token}` } })
this.preferredLocale = data.preferredLocale || this.preferredLocale
} catch {}
},
async updatePreferredLocale(locale, token) {
try {
await http.put('/api/users/me/locale', { locale }, { headers: { Authorization: `Bearer ${token}` } })
this.preferredLocale = locale
} catch {}
}
}
})
src/pages/AdminI18nPage.vue
<script setup>
import { onMounted, reactive } from 'vue'
import { useAuthStore } from '../stores/authStore'
import { useI18nStore } from '../stores/i18nStore'
import { useI18n } from 'vue-i18n'
const auth = useAuthStore()
const i18nStore = useI18nStore()
const { t, locale } = useI18n()
const form = reactive({ languages: [], drinks: {}, sweetness: {}, ice: {} })
onMounted(async () => {
await i18nStore.loadServerConfig()
form.languages = [...i18nStore.languages]
form.drinks = JSON.parse(JSON.stringify(i18nStore.dict.drinks))
form.sweetness = JSON.parse(JSON.stringify(i18nStore.dict.sweetness))
form.ice = JSON.parse(JSON.stringify(i18nStore.dict.ice))
})
function addKey(cat) {
const key = prompt('新增鍵(例如 綠茶)')
if (!key) return
if (!form[cat][key]) form[cat][key] = {}
for (const lang of form.languages) {
if (!form[cat][key][lang]) form[cat][key][lang] = ''
}
}
async function save() {
await i18nStore.saveServerConfig({ languages: form.languages, drinks: form.drinks, sweetness: form.sweetness, ice: form.ice }, auth.token)
}
function setLocale(lang) {
locale.value = lang
}
</script>
<template>
<section class="page">
<h2>{{ t('pages.stats') }} Admin i18n</h2>
<div v-if="i18nStore.error" class="error-message">{{ i18nStore.error }}</div>
<div class="block">
<label>UI 語系:
<select class="btn" @change="setLocale($event.target.value)">
<option value="zh-TW">中文</option>
<option value="en-US">English</option>
<option value="ja-JP">日本語</option>
</select>
</label>
</div>
<div class="block">
<h3>飲料字典</h3>
<button class="btn btn-sm" @click="addKey('drinks')">新增鍵</button>
<div v-for="(row, key) in form.drinks" :key="key" class="block">
<div><b>{{ key }}</b></div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:6px">
<label v-for="lang in form.languages" :key="lang">{{ lang }}:<input v-model="form.drinks[key][lang]" /></label>
</div>
</div>
</div>
<div class="block">
<h3>甜度字典</h3>
<button class="btn btn-sm" @click="addKey('sweetness')">新增鍵</button>
<div v-for="(row, key) in form.sweetness" :key="key" class="block">
<div><b>{{ key }}</b></div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:6px">
<label v-for="lang in form.languages" :key="lang">{{ lang }}:<input v-model="form.sweetness[key][lang]" /></label>
</div>
</div>
</div>
<div class="block">
<h3>冰量字典</h3>
<button class="btn btn-sm" @click="addKey('ice')">新增鍵</button>
<div v-for="(row, key) in form.ice" :key="key" class="block">
<div><b>{{ key }}</b></div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:6px">
<label v-for="lang in form.languages" :key="lang">{{ lang }}:<input v-model="form.ice[key][lang]" /></label>
</div>
</div>
</div>
<div class="actions" style="margin-top:8px">
<button class="btn primary" @click="save">{{ t('actions.save') }}</button>
</div>
</section>
</template>
$t
(節錄)語系切換時更新
document.title
,若已登入則同步偏好到後端。
<!-- src/App.vue -->
<script setup>
import { useAuthStore } from './stores/authStore'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useI18nStore } from './stores/i18nStore'
const auth = useAuthStore()
const router = useRouter()
const { t, locale } = useI18n()
const i18nStore = useI18nStore()
function logout() { auth.clear(); router.push('/login') }
function switchLocale(lang) {
locale.value = lang
document.title = t('app.title')
if (auth.isAuthenticated) i18nStore.updatePreferredLocale(lang, auth.token)
}
</script>
<template>
<main class="page">
<h1>{{ t('app.header') }}</h1>
<nav style="display:flex; gap:8px; margin:12px 0;">
<router-link to="/order" class="btn">{{ t('nav.order') }}</router-link>
<router-link to="/summary" class="btn">{{ t('nav.summary') }}</router-link>
<span style="flex:1"></span>
<select class="btn" @change="switchLocale($event.target.value)">
<option value="zh-TW">中文</option>
<option value="en-US">English</option>
<option value="ja-JP">日本語</option>
</select>
<button v-if="auth.isAuthenticated" class="btn" @click="logout">登出</button>
</nav>
<router-view />
</main>
</template>
/admin/i18n
與守衛 + afterEach 設標題// 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'
import LoginPage from '../pages/LoginPage.vue'
import AdminI18nPage from '../pages/AdminI18nPage.vue'
import { useAuthStore } from '../stores/authStore'
import { useI18n } from 'vue-i18n'
const routes = [
{ path: '/', redirect: '/order' },
{ path: '/login', component: LoginPage },
{ path: '/order', component: OrderPage },
{ path: '/summary', component: SummaryPage, meta: { requiresAdmin: true } },
{ path: '/order/:id', component: OrderDetailPage },
{ path: '/admin/i18n', component: AdminI18nPage, meta: { requiresAdmin: true } },
]
export const router = createRouter({ history: createWebHistory(), routes })
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' }
})
router.afterEach(() => {
try { const { t } = useI18n(); document.title = t('app.title') } catch {}
})
用 字典轉 label 顯示、但 value 仍是原鍵(不污染資料層)
<!-- src/pages/OrderPage.vue(節錄表單傳入選項的部分) -->
<OrderForm
:disabled="orderStore.loading"
:drinks="menuStore.drinks.map(d => ({ value: d, label: i18nStore.translate('drinks', d, $i18n.locale) }))"
:sweetnessOptions="menuStore.sweetnessOptions.map(s => ({ value: s, label: i18nStore.translate('sweetness', s, $i18n.locale) }))"
:iceOptions="menuStore.iceOptions.map(i => ({ value: i, label: i18nStore.translate('ice', i, $i18n.locale) }))"
:menuRules="menuStore.rules"
@submit="handleSubmit"
/>
{ value, label }
<!-- src/components/OptionGroup.vue(關鍵節錄) -->
<label v-for="opt in options" :key="typeof opt === 'string' ? opt : opt.value" style="margin-right:12px">
<input
type="radio"
:checked="modelValue === (typeof opt === 'string' ? opt : opt.value)"
@change="emit('update:modelValue', (typeof opt === 'string' ? opt : opt.value))"
/>
{{ typeof opt === 'string' ? opt : opt.label }}
</label>
這樣既相容舊的
['紅茶','綠茶']
,也支援新的[{value:'紅茶',label:'Black Tea'}]
。
$t
與顯示翻譯(節錄)OrderForm.vue
(步驟標題改 $t
)OrderStats.vue
(表格標題 $t
)OrderDetailPage.vue
(欄位名稱 $t
)LoginPage.vue
(表單與說明 $t
)
你前面的改動已經很完整,這裡不重貼全部檔案,只提醒:固定文案都換
$t()
。
backend/i18n.json
這是 Admin 可編輯的字典檔,由
/api/i18n-config
讀寫。
{
"languages": ["zh-TW", "en-US", "ja-JP"],
"drinks": {
"紅茶": {"zh-TW": "紅茶", "en-US": "Black Tea", "ja-JP": "こうちゃ"},
"綠茶": {"zh-TW": "綠茶", "en-US": "Green Tea", "ja-JP": "りょくちゃ"},
"巧克力": {"zh-TW": "巧克力", "en-US": "Chocolate", "ja-JP": "チョコレート"},
"抹茶拿鐵": {"zh-TW": "抹茶拿鐵", "en-US": "Matcha Latte", "ja-JP": "抹茶ラテ"}
},
"sweetness": {
"正常甜": {"zh-TW": "正常甜", "en-US": "Regular Sugar", "ja-JP": "普通糖"},
"少糖": {"zh-TW": "少糖", "en-US": "Less Sugar", "ja-JP": "少なめ"},
"去糖": {"zh-TW": "去糖", "en-US": "No Sugar", "ja-JP": "無糖"}
},
"ice": {
"正常冰": {"zh-TW": "正常冰", "en-US": "Regular Ice", "ja-JP": "普通氷"},
"去冰": {"zh-TW": "去冰", "en-US": "Less Ice", "ja-JP": "氷少なめ"},
"熱飲": {"zh-TW": "熱飲", "en-US": "Hot", "ja-JP": "ホット"}
}
}
伺服器端點(
server.js
)前面幫你加過:GET /api/i18n-config
、PUT /api/i18n-config
、GET /api/users/me
、PUT /api/users/me/locale
。
你的版本 OK,無需修改。
$t
正常)這邊我們偷改可可
locales/*.json
,版本控管清楚i18n.json
,Admin 可改、前端即時套用項目 | 前端靜態語系檔(locales/*.json) | 後端動態語系檔(i18n-config API) |
---|---|---|
放置位置 | src/locales/zh-TW.json 、en-US.json 等 |
後端伺服器(如 /api/i18n-config ) |
管理方式 | 隨專案一起打包,部署時固定 | 儲存在後端資料庫或 JSON,可線上修改 |
用途定位 | 固定 UI 文字(按鈕、標題、欄位名) | 動態資料(飲料、甜度、冰量等菜單項目) |
更新頻率 | 少變動,通常版本更新才修改 | 常變動,例如新增新品、調整翻譯 |
是否需發版才能更新 | ✅ 需要重新部署 | ❌ 不需發版,Admin 即可線上編輯 |
存取方式 | Vue i18n 直接載入、以 $t('key') 呼叫 |
透過 Pinia store 連後端 API 取得 |
適合場景 | 介面、系統說明、提示訊息 | 菜單內容、可動態編輯之資料翻譯 |
例子 | 「登入」、「點餐之塔」、「結算之室」 | 「綠茶 → Green Tea → 緑茶」 |
語系來源 | 前端打包時讀取 | 後端 API 回傳 JSON |
適合誰改 | 前端工程師或翻譯人員 | 管理者(Admin 面板) |
典型使用方式 | t('actions.save') |
i18nStore.translate('drinks', '綠茶', 'en-US') |
📌 一句話總結:
「靜態檔是語言魔法書,寫死在卷軸上;
動態檔是魔法字典,讓 Admin 能隨時改動咒語而不需重啟整座塔。」 ✨