在Day25中,我們成功完成了Vercel自動化CI/CD部署與NeonPostgreSQL雲端上雲,讓VibePulse正式進入生產環境。
今天(Day26),我們將為VibePulse注入國際化能力—多語系支援(i18n)!
為了讓產品能服務全球學習者,我們將使用Next.jsAppRouter生態系中最主流且效能極佳的next-intl套件,實作:
語系路由與Middleware配置:支援/zh-TW/dashboard與/en/dashboard語系前綴路由,自動偵測瀏覽器預設語言。
多語系JSON字典檔管理:模組化管理繁體中文(zh-TW.json)與英文(en.json)翻譯辭典。
動態語系切換組件(LanguageSwitcher):無刷新即時切換介面語言,並保持當前頁面狀態。
1.系統架構與路由設計
使用next-intl時,我們的AppRouter目錄結構會稍作調整,將需要國際化的頁面統一包覆在[locale]動態路由資料夾下。
2.實戰步驟1:安裝套件與配置Middleware
在Terminal執行安裝:
npm install next-intl
A.建立i18n/request.ts配置檔
// i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
export const locales = ['zh-TW', 'en'] as const;
export const defaultLocale = 'zh-TW';
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
if (!locale || !locales.includes(locale as any)) {
locale = defaultLocale;
}
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
};
});
B.設定middleware.ts語系自動偵測與路由重導向
// middleware.ts
import createMiddleware from 'next-intl/middleware';
import { locales, defaultLocale } from './i18n/request';
export default createMiddleware({
locales,
defaultLocale,
localePrefix: 'always', // 路由永遠帶有語系前綴,如 /zh-TW/dashboard
});
export const config = {
// 匹配所有需要國際化的路徑,排除 _next、static 與 api
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)'],
};
3.實戰步驟2:建立多語系字典JSON檔案
在專案根目錄下建立messages/zh-TW.json與messages/en.json:
TW messages/zh-TW.json
{
"Navigation": {
"dashboard": "儀表板",
"skills": "技能圖譜",
"notes": "學習筆記"
},
"Dashboard": {
"title": "全站數據儀表板",
"subtitle": "量化你的學習歷程,即時掌握技能累積與練習熱度",
"kpi": {
"totalSkills": "總追蹤技能",
"avgProficiency": "平均熟練度",
"practiceHours": "累積練習時數",
"totalNotes": "學習筆記總數"
},
"hours": "小時",
"items": "項",
"articles": "篇"
}
}
4.實戰步驟3:配置語系RootLayout(app/[locale]/layout.tsx)
在app/[locale]/layout.tsx中使用NextIntlClientProvider包覆全站,讓Server與ClientComponent皆可存取語系字典:
// app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { locales } from '@/i18n/request';
export default async function LocaleLayout({
children,
params: { locale },
}: {
children: React.ReactNode;
params: { locale: string };
}) {
if (!locales.includes(locale as any)) {
notFound();
}
const messages = await getMessages();
return (
<html lang={locale}>
<body className="bg-slate-950 text-slate-100 min-h-screen">
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
5.實戰步驟4:打造語系切換選單(components/LanguageSwitcher.tsx)
我們建立一個動態切換語系的選單,切換時能精準保留當前路徑:
// components/LanguageSwitcher.tsx
'use client';
import { useLocale } from 'next-intl';
import { usePathname, useRouter } from 'next/navigation';
import { Globe } from 'lucide-react';
export function LanguageSwitcher() {
const currentLocale = useLocale();
const router = useRouter();
const pathname = usePathname();
const handleLanguageChange = (newLocale: string) => {
// 替換當前 URL 的語系前綴 (例如 /zh-TW/dashboard -> /en/dashboard)
const newPath = pathname.replace(`/${currentLocale}`, `/${newLocale}`);
router.push(newPath);
};
return (
<div className="flex items-center gap-2 bg-slate-900 border border-slate-800 px-3 py-1.5 rounded-xl text-xs font-semibold text-slate-300">
<Globe className="h-3.5 w-3.5 text-cyan-400" />
<select
value={currentLocale}
onChange={(e) => handleLanguageChange(e.target.value)}
className="bg-transparent text-slate-200 focus:outline-none cursor-pointer"
>
<option value="zh-TW" className="bg-slate-900">繁體中文</option>
<option value="en" className="bg-slate-900">English</option>
</select>
</div>
);
}
6.實戰步驟5:在頁面中使用多語系辭典(useTranslations)
以app/[locale]/dashboard/page.tsx為例,使用useTranslations或getTranslations讀取對應語系文字:
// app/[locale]/dashboard/page.tsx (Server Component)
import { getTranslations } from 'next-intl/server';
import { getDashboardStats } from '@/lib/dashboard';
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
export default async function DashboardPage() {
const t = await getTranslations('Dashboard');
const stats = await getDashboardStats();
return (
<div className="max-w-7xl mx-auto px-4 py-8 space-y-8">
{/* 頂部 Header 與語系切換 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-extrabold text-slate-100">{t('title')}</h1>
<p className="text-slate-400 text-sm mt-1">{t('subtitle')}</p>
</div>
<LanguageSwitcher />
</div>
{/* KPI 卡片區域 */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="p-5 rounded-2xl border border-slate-800 bg-slate-900/60">
<span className="text-xs text-slate-400">{t('kpi.totalSkills')}</span>
<div className="text-2xl font-black text-slate-100 mt-1">
{stats.totalSkills} {t('items')}
</div>
</div>
<div className="p-5 rounded-2xl border border-slate-800 bg-slate-900/60">
<span className="text-xs text-slate-400">{t('kpi.practiceHours')}</span>
<div className="text-2xl font-black text-slate-100 mt-1">
{Math.round(stats.totalPracticeMinutes / 60)} {t('hours')}
</div>
</div>
</div>
</div>
);
}
今天我們完成了VibePulse的國際化架構:
next-intl路由隔離:透過[locale]動態路由與Middleware,實現無縫的URL語系路由管理。
模組化JSON字典:將全站UI文字抽離至messages/資料夾,支援動態擴充更多語言。
流暢的切換體驗:實作LanguageSwitcher組件,讓使用者能隨時切換繁體中文與英文介面!