相信到這個地步,我們身為厲害的魔法師
一定會需要與各國的魔法師交流~這時候就需要多國語言的處理~!!
今天我們把語言切換這門魔法真正施展起來!
在小型專案裡,直接把文案寫死在前端就夠用了;但當飲料口味一路從紅茶、抹茶、巧克力長到「一整張菜單」、還要支援多國旅人時,把文案與資料分離就成了必修咒語。於是本章我們同時準備兩把魔杖:
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.jsimport { 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 能隨時改動咒語而不需重啟整座塔。」 ✨