第60天:Element Plus 多语言切换实现与动态配置
学习目标
- 掌握 Element Plus 多语言系统的高级应用
- 学会实现动态语言切换功能
- 了解多语言配置的最佳实践
- 掌握复杂业务场景下的国际化解决方案
知识点概览
1. 多语言系统架构设计
1.1 国际化架构规划
typescript
// types/i18n.ts
export interface I18nConfig {
// 默认语言
defaultLocale: string
// 支持的语言列表
supportedLocales: LocaleInfo[]
// 语言检测策略
detection: {
// 检测顺序
order: ('localStorage' | 'navigator' | 'header' | 'cookie')[]
// 缓存配置
cache: {
enabled: boolean
key: string
expires: number
}
}
// 回退策略
fallback: {
enabled: boolean
locale: string
}
// 懒加载配置
lazy: {
enabled: boolean
chunkName: string
}
// 插值配置
interpolation: {
escapeValue: boolean
format: (value: any, format: string, lng: string) => string
}
}
export interface LocaleInfo {
code: string
name: string
nativeName: string
flag: string
direction: 'ltr' | 'rtl'
dateFormat: string
timeFormat: string
numberFormat: {
decimal: string
thousands: string
currency: string
}
enabled: boolean
}
export interface TranslationResource {
[key: string]: string | TranslationResource
}
export interface I18nState {
currentLocale: string
availableLocales: LocaleInfo[]
translations: Record<string, TranslationResource>
loading: boolean
error: string | null
}
1.2 多语言状态管理
typescript
// stores/i18n.ts
import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { I18nState, LocaleInfo, TranslationResource } from '@/types/i18n'
export const useI18nStore = defineStore('i18n', () => {
// 状态
const state = ref<I18nState>({
currentLocale: 'zh-CN',
availableLocales: [],
translations: {},
loading: false,
error: null
})
// 配置
const config = ref({
defaultLocale: 'zh-CN',
supportedLocales: [
{
code: 'zh-CN',
name: 'Chinese (Simplified)',
nativeName: '简体中文',
flag: '🇨🇳',
direction: 'ltr',
dateFormat: 'YYYY-MM-DD',
timeFormat: 'HH:mm:ss',
numberFormat: {
decimal: '.',
thousands: ',',
currency: '¥'
},
enabled: true
},
{
code: 'en-US',
name: 'English (US)',
nativeName: 'English',
flag: '🇺🇸',
direction: 'ltr',
dateFormat: 'MM/DD/YYYY',
timeFormat: 'hh:mm:ss A',
numberFormat: {
decimal: '.',
thousands: ',',
currency: '$'
},
enabled: true
},
{
code: 'ja-JP',
name: 'Japanese',
nativeName: '日本語',
flag: '🇯🇵',
direction: 'ltr',
dateFormat: 'YYYY/MM/DD',
timeFormat: 'HH:mm:ss',
numberFormat: {
decimal: '.',
thousands: ',',
currency: '¥'
},
enabled: true
},
{
code: 'ko-KR',
name: 'Korean',
nativeName: '한국어',
flag: '🇰🇷',
direction: 'ltr',
dateFormat: 'YYYY.MM.DD',
timeFormat: 'HH:mm:ss',
numberFormat: {
decimal: '.',
thousands: ',',
currency: '₩'
},
enabled: true
},
{
code: 'ar-SA',
name: 'Arabic',
nativeName: 'العربية',
flag: '🇸🇦',
direction: 'rtl',
dateFormat: 'DD/MM/YYYY',
timeFormat: 'HH:mm:ss',
numberFormat: {
decimal: '.',
thousands: ',',
currency: 'ر.س'
},
enabled: true
}
] as LocaleInfo[],
detection: {
order: ['localStorage', 'navigator', 'header'],
cache: {
enabled: true,
key: 'app_locale',
expires: 30 * 24 * 60 * 60 * 1000 // 30 days
}
},
fallback: {
enabled: true,
locale: 'en-US'
},
lazy: {
enabled: true,
chunkName: 'locale-[locale]'
}
})
// 计算属性
const currentLocaleInfo = computed(() => {
return state.value.availableLocales.find(
locale => locale.code === state.value.currentLocale
) || config.value.supportedLocales[0]
})
const isRTL = computed(() => {
return currentLocaleInfo.value.direction === 'rtl'
})
const enabledLocales = computed(() => {
return state.value.availableLocales.filter(locale => locale.enabled)
})
// 初始化
const initialize = async () => {
try {
state.value.loading = true
state.value.availableLocales = config.value.supportedLocales
// 检测用户语言偏好
const detectedLocale = detectUserLocale()
// 设置初始语言
await setLocale(detectedLocale)
state.value.error = null
} catch (error) {
state.value.error = '初始化多语言系统失败'
console.error('I18n initialization failed:', error)
} finally {
state.value.loading = false
}
}
// 检测用户语言偏好
const detectUserLocale = (): string => {
const { order, cache } = config.value.detection
for (const method of order) {
let detectedLocale: string | null = null
switch (method) {
case 'localStorage':
if (cache.enabled) {
detectedLocale = localStorage.getItem(cache.key)
}
break
case 'navigator':
detectedLocale = navigator.language || navigator.languages[0]
break
case 'header':
// 在服务端渲染时从请求头获取
if (typeof window === 'undefined') {
// SSR 环境下的语言检测逻辑
}
break
case 'cookie':
// 从 cookie 中获取语言设置
detectedLocale = getCookieValue('locale')
break
}
if (detectedLocale && isLocaleSupported(detectedLocale)) {
return detectedLocale
}
}
return config.value.defaultLocale
}
// 检查语言是否支持
const isLocaleSupported = (locale: string): boolean => {
return config.value.supportedLocales.some(
supportedLocale => supportedLocale.code === locale
)
}
// 设置语言
const setLocale = async (locale: string) => {
if (!isLocaleSupported(locale)) {
console.warn(`Locale ${locale} is not supported`)
locale = config.value.fallback.enabled
? config.value.fallback.locale
: config.value.defaultLocale
}
try {
state.value.loading = true
// 加载翻译资源
await loadTranslations(locale)
// 更新当前语言
state.value.currentLocale = locale
// 缓存语言设置
if (config.value.detection.cache.enabled) {
localStorage.setItem(
config.value.detection.cache.key,
locale
)
}
// 更新 HTML 属性
updateHTMLAttributes(locale)
// 触发语言变更事件
emitLocaleChange(locale)
ElMessage.success(`语言已切换为 ${currentLocaleInfo.value.nativeName}`)
} catch (error) {
state.value.error = `切换语言失败: ${error}`
ElMessage.error('语言切换失败')
throw error
} finally {
state.value.loading = false
}
}
// 加载翻译资源
const loadTranslations = async (locale: string) => {
if (state.value.translations[locale]) {
return // 已加载
}
try {
let translations: TranslationResource
if (config.value.lazy.enabled) {
// 懒加载翻译文件
const module = await import(`@/locales/${locale}.json`)
translations = module.default || module
} else {
// 静态导入
const response = await fetch(`/locales/${locale}.json`)
translations = await response.json()
}
state.value.translations[locale] = translations
} catch (error) {
console.error(`Failed to load translations for ${locale}:`, error)
// 如果加载失败且启用了回退,尝试加载回退语言
if (config.value.fallback.enabled && locale !== config.value.fallback.locale) {
await loadTranslations(config.value.fallback.locale)
}
throw error
}
}
// 更新 HTML 属性
const updateHTMLAttributes = (locale: string) => {
const localeInfo = config.value.supportedLocales.find(
l => l.code === locale
)
if (localeInfo) {
document.documentElement.lang = locale
document.documentElement.dir = localeInfo.direction
// 更新 CSS 变量
document.documentElement.style.setProperty(
'--text-direction',
localeInfo.direction
)
}
}
// 触发语言变更事件
const emitLocaleChange = (locale: string) => {
const event = new CustomEvent('localeChange', {
detail: { locale, localeInfo: currentLocaleInfo.value }
})
window.dispatchEvent(event)
}
// 翻译函数
const t = (key: string, params?: Record<string, any>): string => {
const translations = state.value.translations[state.value.currentLocale]
if (!translations) {
return key
}
const value = getNestedValue(translations, key)
if (value === undefined) {
// 尝试回退语言
if (config.value.fallback.enabled) {
const fallbackTranslations = state.value.translations[config.value.fallback.locale]
if (fallbackTranslations) {
const fallbackValue = getNestedValue(fallbackTranslations, key)
if (fallbackValue !== undefined) {
return interpolate(fallbackValue, params)
}
}
}
console.warn(`Translation key "${key}" not found for locale "${state.value.currentLocale}"`)
return key
}
return interpolate(value, params)
}
// 获取嵌套对象值
const getNestedValue = (obj: any, path: string): string | undefined => {
return path.split('.').reduce((current, key) => {
return current && current[key] !== undefined ? current[key] : undefined
}, obj)
}
// 字符串插值
const interpolate = (template: string, params?: Record<string, any>): string => {
if (!params) return template
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => {
return params[key] !== undefined ? String(params[key]) : match
})
}
// 格式化数字
const formatNumber = (value: number, options?: Intl.NumberFormatOptions): string => {
const locale = state.value.currentLocale
return new Intl.NumberFormat(locale, options).format(value)
}
// 格式化货币
const formatCurrency = (value: number, currency?: string): string => {
const locale = state.value.currentLocale
const localeInfo = currentLocaleInfo.value
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency || localeInfo.numberFormat.currency.replace(/[^A-Z]/g, '') || 'USD'
}).format(value)
}
// 格式化日期
const formatDate = (date: Date | string, options?: Intl.DateTimeFormatOptions): string => {
const locale = state.value.currentLocale
const dateObj = typeof date === 'string' ? new Date(date) : date
return new Intl.DateTimeFormat(locale, options).format(dateObj)
}
// 格式化相对时间
const formatRelativeTime = (value: number, unit: Intl.RelativeTimeFormatUnit): string => {
const locale = state.value.currentLocale
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })
return rtf.format(value, unit)
}
// 获取 Cookie 值
const getCookieValue = (name: string): string | null => {
const value = `; ${document.cookie}`
const parts = value.split(`; ${name}=`)
if (parts.length === 2) {
return parts.pop()?.split(';').shift() || null
}
return null
}
// 添加新语言
const addLocale = (localeInfo: LocaleInfo) => {
const existingIndex = state.value.availableLocales.findIndex(
locale => locale.code === localeInfo.code
)
if (existingIndex >= 0) {
state.value.availableLocales[existingIndex] = localeInfo
} else {
state.value.availableLocales.push(localeInfo)
}
}
// 移除语言
const removeLocale = (localeCode: string) => {
const index = state.value.availableLocales.findIndex(
locale => locale.code === localeCode
)
if (index >= 0) {
state.value.availableLocales.splice(index, 1)
// 如果移除的是当前语言,切换到默认语言
if (state.value.currentLocale === localeCode) {
setLocale(config.value.defaultLocale)
}
}
}
// 启用/禁用语言
const toggleLocale = (localeCode: string, enabled: boolean) => {
const locale = state.value.availableLocales.find(
l => l.code === localeCode
)
if (locale) {
locale.enabled = enabled
// 如果禁用的是当前语言,切换到默认语言
if (!enabled && state.value.currentLocale === localeCode) {
setLocale(config.value.defaultLocale)
}
}
}
// 监听语言变化
watch(
() => state.value.currentLocale,
(newLocale) => {
// 更新 Element Plus 语言
updateElementPlusLocale(newLocale)
},
{ immediate: true }
)
// 更新 Element Plus 语言
const updateElementPlusLocale = async (locale: string) => {
try {
let elementLocale
switch (locale) {
case 'zh-CN':
elementLocale = (await import('element-plus/dist/locale/zh-cn.mjs')).default
break
case 'en-US':
elementLocale = (await import('element-plus/dist/locale/en.mjs')).default
break
case 'ja-JP':
elementLocale = (await import('element-plus/dist/locale/ja.mjs')).default
break
case 'ko-KR':
elementLocale = (await import('element-plus/dist/locale/ko.mjs')).default
break
case 'ar-SA':
elementLocale = (await import('element-plus/dist/locale/ar.mjs')).default
break
default:
elementLocale = (await import('element-plus/dist/locale/en.mjs')).default
}
// 这里需要与 Element Plus 的配置提供者集成
// 具体实现取决于应用的架构
} catch (error) {
console.error('Failed to update Element Plus locale:', error)
}
}
return {
// 状态
state: readonly(state),
config: readonly(config),
// 计算属性
currentLocaleInfo,
isRTL,
enabledLocales,
// 方法
initialize,
setLocale,
addLocale,
removeLocale,
toggleLocale,
t,
formatNumber,
formatCurrency,
formatDate,
formatRelativeTime
}
})
2. 动态语言切换组件
2.1 语言选择器组件
vue
<!-- components/LanguageSelector/LanguageSelector.vue -->
<template>
<div class="language-selector">
<!-- 下拉选择器模式 -->
<el-dropdown
v-if="mode === 'dropdown'"
:class="['language-selector__dropdown', { 'is-loading': loading }]"
trigger="click"
@command="handleLocaleChange"
>
<el-button
:type="buttonType"
:size="size"
:loading="loading"
:disabled="disabled"
>
<span class="language-selector__current">
<span v-if="showFlag" class="language-selector__flag">
{{ currentLocaleInfo.flag }}
</span>
<span v-if="showName" class="language-selector__name">
{{ showNativeName ? currentLocaleInfo.nativeName : currentLocaleInfo.name }}
</span>
<span v-if="showCode" class="language-selector__code">
({{ currentLocaleInfo.code }})
</span>
</span>
<el-icon class="el-icon--right">
<arrow-down />
</el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="locale in availableLocales"
:key="locale.code"
:command="locale.code"
:disabled="locale.code === currentLocale || !locale.enabled"
:class="{
'is-active': locale.code === currentLocale,
'is-disabled': !locale.enabled
}"
>
<div class="language-selector__option">
<span v-if="showFlag" class="language-selector__flag">
{{ locale.flag }}
</span>
<span class="language-selector__info">
<span class="language-selector__name">
{{ showNativeName ? locale.nativeName : locale.name }}
</span>
<span v-if="showCode" class="language-selector__code">
{{ locale.code }}
</span>
</span>
<el-icon v-if="locale.code === currentLocale" class="language-selector__check">
<check />
</el-icon>
</div>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 按钮组模式 -->
<el-button-group v-else-if="mode === 'buttons'" class="language-selector__buttons">
<el-button
v-for="locale in availableLocales"
:key="locale.code"
:type="locale.code === currentLocale ? 'primary' : 'default'"
:size="size"
:loading="loading && locale.code === pendingLocale"
:disabled="disabled || !locale.enabled"
@click="handleLocaleChange(locale.code)"
>
<span v-if="showFlag" class="language-selector__flag">
{{ locale.flag }}
</span>
<span v-if="showName">
{{ showNativeName ? locale.nativeName : locale.name }}
</span>
<span v-if="showCode" class="language-selector__code">
({{ locale.code }})
</span>
</el-button>
</el-button-group>
<!-- 选择器模式 -->
<el-select
v-else-if="mode === 'select'"
:model-value="currentLocale"
:size="size"
:loading="loading"
:disabled="disabled"
:placeholder="placeholder"
:clearable="false"
class="language-selector__select"
@change="handleLocaleChange"
>
<el-option
v-for="locale in availableLocales"
:key="locale.code"
:label="getOptionLabel(locale)"
:value="locale.code"
:disabled="!locale.enabled"
>
<div class="language-selector__option">
<span v-if="showFlag" class="language-selector__flag">
{{ locale.flag }}
</span>
<span class="language-selector__info">
<span class="language-selector__name">
{{ showNativeName ? locale.nativeName : locale.name }}
</span>
<span v-if="showCode" class="language-selector__code">
{{ locale.code }}
</span>
</span>
</div>
</el-option>
</el-select>
<!-- 标签页模式 -->
<el-tabs
v-else-if="mode === 'tabs'"
:model-value="currentLocale"
:size="size"
:type="tabType"
class="language-selector__tabs"
@tab-click="handleTabClick"
>
<el-tab-pane
v-for="locale in availableLocales"
:key="locale.code"
:label="getTabLabel(locale)"
:name="locale.code"
:disabled="!locale.enabled"
>
<template #label>
<div class="language-selector__tab">
<span v-if="showFlag" class="language-selector__flag">
{{ locale.flag }}
</span>
<span v-if="showName">
{{ showNativeName ? locale.nativeName : locale.name }}
</span>
</div>
</template>
</el-tab-pane>
</el-tabs>
<!-- 自定义模式 -->
<div v-else class="language-selector__custom">
<slot
:locales="availableLocales"
:current-locale="currentLocale"
:current-locale-info="currentLocaleInfo"
:loading="loading"
:disabled="disabled"
:change-locale="handleLocaleChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElDropdown, ElDropdownMenu, ElDropdownItem, ElButton, ElButtonGroup, ElSelect, ElOption, ElTabs, ElTabPane, ElIcon, ElMessage } from 'element-plus'
import { ArrowDown, Check } from '@element-plus/icons-vue'
import { useI18nStore } from '@/stores/i18n'
import type { LocaleInfo } from '@/types/i18n'
import type { TabsPaneContext } from 'element-plus'
// Props
interface LanguageSelectorProps {
mode?: 'dropdown' | 'buttons' | 'select' | 'tabs' | 'custom'
size?: 'large' | 'default' | 'small'
buttonType?: 'primary' | 'success' | 'warning' | 'danger' | 'info' | 'text'
tabType?: 'card' | 'border-card'
showFlag?: boolean
showName?: boolean
showCode?: boolean
showNativeName?: boolean
disabled?: boolean
placeholder?: string
confirmChange?: boolean
confirmMessage?: string
}
const props = withDefaults(defineProps<LanguageSelectorProps>(), {
mode: 'dropdown',
size: 'default',
buttonType: 'default',
tabType: 'card',
showFlag: true,
showName: true,
showCode: false,
showNativeName: true,
disabled: false,
placeholder: '选择语言',
confirmChange: false,
confirmMessage: '确定要切换语言吗?'
})
// Emits
interface LanguageSelectorEmits {
'before-change': [locale: string]
'change': [locale: string, oldLocale: string]
'error': [error: string]
}
const emit = defineEmits<LanguageSelectorEmits>()
// Store
const i18nStore = useI18nStore()
// Refs
const loading = ref(false)
const pendingLocale = ref('')
// Computed
const currentLocale = computed(() => i18nStore.state.currentLocale)
const currentLocaleInfo = computed(() => i18nStore.currentLocaleInfo)
const availableLocales = computed(() => i18nStore.enabledLocales)
// Methods
const handleLocaleChange = async (locale: string) => {
if (locale === currentLocale.value || loading.value) {
return
}
try {
// 触发变更前事件
emit('before-change', locale)
// 确认变更
if (props.confirmChange) {
await ElMessageBox.confirm(
props.confirmMessage,
'确认',
{
type: 'info',
confirmButtonText: '确定',
cancelButtonText: '取消'
}
)
}
loading.value = true
pendingLocale.value = locale
const oldLocale = currentLocale.value
// 切换语言
await i18nStore.setLocale(locale)
// 触发变更事件
emit('change', locale, oldLocale)
} catch (error) {
if (error !== 'cancel') {
const errorMessage = error instanceof Error ? error.message : '语言切换失败'
emit('error', errorMessage)
ElMessage.error(errorMessage)
}
} finally {
loading.value = false
pendingLocale.value = ''
}
}
const handleTabClick = (tab: TabsPaneContext) => {
if (tab.props.name) {
handleLocaleChange(tab.props.name as string)
}
}
const getOptionLabel = (locale: LocaleInfo): string => {
const parts = []
if (props.showFlag) parts.push(locale.flag)
if (props.showName) {
parts.push(props.showNativeName ? locale.nativeName : locale.name)
}
if (props.showCode) parts.push(`(${locale.code})`)
return parts.join(' ')
}
const getTabLabel = (locale: LocaleInfo): string => {
return getOptionLabel(locale)
}
</script>
<style lang="scss" scoped>
.language-selector {
&__dropdown {
&.is-loading {
pointer-events: none;
}
}
&__current {
display: flex;
align-items: center;
gap: 6px;
}
&__option {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 8px;
}
&__info {
display: flex;
flex-direction: column;
flex: 1;
}
&__flag {
font-size: 16px;
line-height: 1;
}
&__name {
font-weight: 500;
}
&__code {
font-size: 12px;
color: var(--el-text-color-secondary);
}
&__check {
color: var(--el-color-primary);
}
&__buttons {
.language-selector__flag {
margin-right: 4px;
}
.language-selector__code {
margin-left: 4px;
}
}
&__select {
min-width: 150px;
}
&__tabs {
.language-selector__tab {
display: flex;
align-items: center;
gap: 6px;
}
}
&__custom {
// 自定义样式由使用者定义
}
}
// RTL 支持
[dir="rtl"] {
.language-selector {
&__current,
&__option,
&__tab {
flex-direction: row-reverse;
}
&__buttons {
.language-selector__flag {
margin-right: 0;
margin-left: 4px;
}
.language-selector__code {
margin-left: 0;
margin-right: 4px;
}
}
}
}
</style>
2.2 语言切换快捷操作
vue
<!-- components/LanguageQuickSwitch/LanguageQuickSwitch.vue -->
<template>
<div class="language-quick-switch">
<!-- 浮动按钮 -->
<el-affix v-if="mode === 'float'" :offset="offset" :position="position">
<div class="language-quick-switch__float">
<el-button
:type="buttonType"
:size="size"
circle
@click="togglePanel"
>
<el-icon>
<globe />
</el-icon>
</el-button>
<transition name="slide-fade">
<div v-show="panelVisible" class="language-quick-switch__panel">
<div class="language-quick-switch__header">
<span>{{ t('common.selectLanguage') }}</span>
<el-button
type="text"
size="small"
@click="panelVisible = false"
>
<el-icon><close /></el-icon>
</el-button>
</div>
<div class="language-quick-switch__list">
<div
v-for="locale in availableLocales"
:key="locale.code"
:class="[
'language-quick-switch__item',
{
'is-active': locale.code === currentLocale,
'is-disabled': !locale.enabled
}
]"
@click="handleQuickSwitch(locale.code)"
>
<span class="language-quick-switch__flag">{{ locale.flag }}</span>
<span class="language-quick-switch__name">{{ locale.nativeName }}</span>
<el-icon v-if="locale.code === currentLocale" class="language-quick-switch__check">
<check />
</el-icon>
</div>
</div>
</div>
</transition>
</div>
</el-affix>
<!-- 键盘快捷键 -->
<div v-if="mode === 'keyboard'" class="language-quick-switch__keyboard">
<el-tooltip
:content="`${t('common.pressKey')} ${shortcutKey} ${t('common.toSwitchLanguage')}`"
placement="top"
>
<el-tag size="small" type="info">
<el-icon><keyboard /></el-icon>
{{ shortcutKey }}
</el-tag>
</el-tooltip>
</div>
<!-- 手势切换 -->
<div
v-if="mode === 'gesture'"
class="language-quick-switch__gesture"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
>
<div class="language-quick-switch__gesture-indicator">
<el-icon><d-arrow-left /></el-icon>
<span>{{ t('common.swipeToSwitchLanguage') }}</span>
<el-icon><d-arrow-right /></el-icon>
</div>
</div>
<!-- 自动检测 -->
<div v-if="mode === 'auto'" class="language-quick-switch__auto">
<el-alert
v-if="showAutoDetectTip"
:title="t('common.autoDetectLanguage')"
type="info"
:closable="true"
show-icon
@close="handleAutoDetectClose"
>
<template #default>
<p>{{ t('common.detectedLanguage', { language: detectedLanguage }) }}</p>
<div class="language-quick-switch__auto-actions">
<el-button
size="small"
type="primary"
@click="handleAcceptDetected"
>
{{ t('common.accept') }}
</el-button>
<el-button
size="small"
@click="handleRejectDetected"
>
{{ t('common.reject') }}
</el-button>
</div>
</template>
</el-alert>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ElAffix, ElButton, ElIcon, ElTooltip, ElTag, ElAlert } from 'element-plus'
import { Globe, Close, Check, Keyboard, DArrowLeft, DArrowRight } from '@element-plus/icons-vue'
import { useI18nStore } from '@/stores/i18n'
// Props
interface LanguageQuickSwitchProps {
mode?: 'float' | 'keyboard' | 'gesture' | 'auto'
position?: 'top' | 'bottom'
offset?: number
size?: 'large' | 'default' | 'small'
buttonType?: 'primary' | 'success' | 'warning' | 'danger' | 'info' | 'text'
shortcutKey?: string
enableGesture?: boolean
autoDetect?: boolean
}
const props = withDefaults(defineProps<LanguageQuickSwitchProps>(), {
mode: 'float',
position: 'bottom',
offset: 20,
size: 'default',
buttonType: 'primary',
shortcutKey: 'Alt+L',
enableGesture: true,
autoDetect: true
})
// Store
const i18nStore = useI18nStore()
// Refs
const panelVisible = ref(false)
const showAutoDetectTip = ref(false)
const detectedLanguage = ref('')
const touchStartX = ref(0)
const touchStartY = ref(0)
// Computed
const currentLocale = computed(() => i18nStore.state.currentLocale)
const availableLocales = computed(() => i18nStore.enabledLocales)
const { t } = i18nStore
// Methods
const togglePanel = () => {
panelVisible.value = !panelVisible.value
}
const handleQuickSwitch = async (locale: string) => {
if (locale === currentLocale.value) return
try {
await i18nStore.setLocale(locale)
panelVisible.value = false
} catch (error) {
console.error('Quick switch failed:', error)
}
}
// 键盘快捷键处理
const handleKeydown = (event: KeyboardEvent) => {
if (props.mode !== 'keyboard') return
const { altKey, ctrlKey, key } = event
const shortcut = props.shortcutKey.toLowerCase()
if (shortcut.includes('alt') && altKey && key.toLowerCase() === 'l') {
event.preventDefault()
switchToNextLanguage()
} else if (shortcut.includes('ctrl') && ctrlKey && key.toLowerCase() === 'l') {
event.preventDefault()
switchToNextLanguage()
}
}
const switchToNextLanguage = () => {
const currentIndex = availableLocales.value.findIndex(
locale => locale.code === currentLocale.value
)
const nextIndex = (currentIndex + 1) % availableLocales.value.length
const nextLocale = availableLocales.value[nextIndex]
if (nextLocale) {
handleQuickSwitch(nextLocale.code)
}
}
// 手势处理
const handleTouchStart = (event: TouchEvent) => {
if (!props.enableGesture) return
const touch = event.touches[0]
touchStartX.value = touch.clientX
touchStartY.value = touch.clientY
}
const handleTouchMove = (event: TouchEvent) => {
if (!props.enableGesture) return
event.preventDefault()
}
const handleTouchEnd = (event: TouchEvent) => {
if (!props.enableGesture) return
const touch = event.changedTouches[0]
const deltaX = touch.clientX - touchStartX.value
const deltaY = touch.clientY - touchStartY.value
// 检查是否为水平滑动
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 50) {
if (deltaX > 0) {
// 向右滑动,切换到上一个语言
switchToPreviousLanguage()
} else {
// 向左滑动,切换到下一个语言
switchToNextLanguage()
}
}
}
const switchToPreviousLanguage = () => {
const currentIndex = availableLocales.value.findIndex(
locale => locale.code === currentLocale.value
)
const prevIndex = currentIndex === 0
? availableLocales.value.length - 1
: currentIndex - 1
const prevLocale = availableLocales.value[prevIndex]
if (prevLocale) {
handleQuickSwitch(prevLocale.code)
}
}
// 自动检测处理
const detectUserLanguage = () => {
if (!props.autoDetect) return
const browserLanguage = navigator.language || navigator.languages[0]
const supportedLocale = availableLocales.value.find(
locale => locale.code === browserLanguage
)
if (supportedLocale && supportedLocale.code !== currentLocale.value) {
detectedLanguage.value = supportedLocale.nativeName
showAutoDetectTip.value = true
}
}
const handleAcceptDetected = () => {
const detectedLocale = availableLocales.value.find(
locale => locale.nativeName === detectedLanguage.value
)
if (detectedLocale) {
handleQuickSwitch(detectedLocale.code)
}
showAutoDetectTip.value = false
}
const handleRejectDetected = () => {
showAutoDetectTip.value = false
// 记住用户的选择,避免重复提示
localStorage.setItem('language-auto-detect-rejected', 'true')
}
const handleAutoDetectClose = () => {
showAutoDetectTip.value = false
}
// 生命周期
onMounted(() => {
if (props.mode === 'keyboard') {
document.addEventListener('keydown', handleKeydown)
}
if (props.mode === 'auto') {
const rejected = localStorage.getItem('language-auto-detect-rejected')
if (!rejected) {
setTimeout(detectUserLanguage, 1000)
}
}
// 点击外部关闭面板
document.addEventListener('click', (event) => {
const target = event.target as Element
if (!target.closest('.language-quick-switch__float')) {
panelVisible.value = false
}
})
})
onUnmounted(() => {
if (props.mode === 'keyboard') {
document.removeEventListener('keydown', handleKeydown)
}
})
</script>
<style lang="scss" scoped>
.language-quick-switch {
&__float {
position: relative;
}
&__panel {
position: absolute;
bottom: 60px;
right: 0;
width: 200px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color);
border-radius: 8px;
box-shadow: var(--el-box-shadow);
z-index: 1000;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
font-weight: 500;
}
&__list {
max-height: 300px;
overflow-y: auto;
}
&__item {
display: flex;
align-items: center;
padding: 8px 16px;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background-color: var(--el-fill-color-light);
}
&.is-active {
background-color: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
&.is-disabled {
opacity: 0.5;
cursor: not-allowed;
&:hover {
background-color: transparent;
}
}
}
&__flag {
margin-right: 8px;
font-size: 16px;
}
&__name {
flex: 1;
}
&__check {
color: var(--el-color-primary);
}
&__keyboard {
display: inline-block;
}
&__gesture {
padding: 20px;
text-align: center;
border: 2px dashed var(--el-border-color);
border-radius: 8px;
cursor: pointer;
user-select: none;
&:hover {
border-color: var(--el-color-primary);
}
}
&__gesture-indicator {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--el-text-color-secondary);
}
&__auto-actions {
margin-top: 8px;
display: flex;
gap: 8px;
}
}
// 动画
.slide-fade-enter-active,
.slide-fade-leave-active {
transition: all 0.3s ease;
}
.slide-fade-enter-from {
transform: translateY(10px);
opacity: 0;
}
.slide-fade-leave-to {
transform: translateY(10px);
opacity: 0;
}
// RTL 支持
[dir="rtl"] {
.language-quick-switch {
&__panel {
right: auto;
left: 0;
}
&__flag {
margin-right: 0;
margin-left: 8px;
}
}
}
</style>
3. 翻译资源管理
3.1 翻译文件结构
json
// locales/zh-CN.json
{
"common": {
"confirm": "确认",
"cancel": "取消",
"save": "保存",
"delete": "删除",
"edit": "编辑",
"add": "添加",
"search": "搜索",
"reset": "重置",
"submit": "提交",
"loading": "加载中...",
"noData": "暂无数据",
"selectLanguage": "选择语言",
"pressKey": "按下",
"toSwitchLanguage": "切换语言",
"swipeToSwitchLanguage": "滑动切换语言",
"autoDetectLanguage": "自动检测语言",
"detectedLanguage": "检测到您的语言为 {{language}}",
"accept": "接受",
"reject": "拒绝"
},
"navigation": {
"home": "首页",
"dashboard": "仪表板",
"users": "用户管理",
"settings": "设置",
"profile": "个人资料",
"logout": "退出登录"
},
"user": {
"title": "用户管理",
"description": "管理系统用户账号、角色和权限",
"username": "用户名",
"email": "邮箱",
"phone": "手机号",
"realName": "真实姓名",
"department": "部门",
"position": "职位",
"role": "角色",
"status": "状态",
"lastLogin": "最后登录",
"createTime": "创建时间",
"actions": "操作",
"view": "查看",
"edit": "编辑",
"delete": "删除",
"enable": "启用",
"disable": "禁用",
"addUser": "新增用户",
"editUser": "编辑用户",
"deleteConfirm": "确定要删除用户 \"{{name}}\" 吗?",
"batchDeleteConfirm": "确定要删除选中的 {{count}} 个用户吗?"
},
"form": {
"required": "{{field}} 是必填项",
"email": "请输入正确的邮箱地址",
"phone": "请输入正确的手机号",
"minLength": "{{field}} 长度不能少于 {{min}} 个字符",
"maxLength": "{{field}} 长度不能超过 {{max}} 个字符",
"passwordMismatch": "两次输入密码不一致"
},
"message": {
"success": {
"save": "保存成功",
"delete": "删除成功",
"update": "更新成功",
"create": "创建成功",
"languageChanged": "语言已切换为 {{language}}"
},
"error": {
"save": "保存失败",
"delete": "删除失败",
"update": "更新失败",
"create": "创建失败",
"network": "网络错误,请稍后重试",
"languageChange": "语言切换失败"
}
},
"date": {
"today": "今天",
"yesterday": "昨天",
"tomorrow": "明天",
"thisWeek": "本周",
"lastWeek": "上周",
"nextWeek": "下周",
"thisMonth": "本月",
"lastMonth": "上月",
"nextMonth": "下月",
"thisYear": "今年",
"lastYear": "去年",
"nextYear": "明年"
}
}
json
// locales/en-US.json
{
"common": {
"confirm": "Confirm",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"add": "Add",
"search": "Search",
"reset": "Reset",
"submit": "Submit",
"loading": "Loading...",
"noData": "No Data",
"selectLanguage": "Select Language",
"pressKey": "Press",
"toSwitchLanguage": "to switch language",
"swipeToSwitchLanguage": "Swipe to switch language",
"autoDetectLanguage": "Auto Detect Language",
"detectedLanguage": "Detected your language as {{language}}",
"accept": "Accept",
"reject": "Reject"
},
"navigation": {
"home": "Home",
"dashboard": "Dashboard",
"users": "User Management",
"settings": "Settings",
"profile": "Profile",
"logout": "Logout"
},
"user": {
"title": "User Management",
"description": "Manage system user accounts, roles and permissions",
"username": "Username",
"email": "Email",
"phone": "Phone",
"realName": "Real Name",
"department": "Department",
"position": "Position",
"role": "Role",
"status": "Status",
"lastLogin": "Last Login",
"createTime": "Create Time",
"actions": "Actions",
"view": "View",
"edit": "Edit",
"delete": "Delete",
"enable": "Enable",
"disable": "Disable",
"addUser": "Add User",
"editUser": "Edit User",
"deleteConfirm": "Are you sure to delete user \"{{name}}\"?",
"batchDeleteConfirm": "Are you sure to delete {{count}} selected users?"
},
"form": {
"required": "{{field}} is required",
"email": "Please enter a valid email address",
"phone": "Please enter a valid phone number",
"minLength": "{{field}} must be at least {{min}} characters",
"maxLength": "{{field}} cannot exceed {{max}} characters",
"passwordMismatch": "Passwords do not match"
},
"message": {
"success": {
"save": "Saved successfully",
"delete": "Deleted successfully",
"update": "Updated successfully",
"create": "Created successfully",
"languageChanged": "Language changed to {{language}}"
},
"error": {
"save": "Save failed",
"delete": "Delete failed",
"update": "Update failed",
"create": "Create failed",
"network": "Network error, please try again later",
"languageChange": "Language change failed"
}
},
"date": {
"today": "Today",
"yesterday": "Yesterday",
"tomorrow": "Tomorrow",
"thisWeek": "This Week",
"lastWeek": "Last Week",
"nextWeek": "Next Week",
"thisMonth": "This Month",
"lastMonth": "Last Month",
"nextMonth": "Next Month",
"thisYear": "This Year",
"lastYear": "Last Year",
"nextYear": "Next Year"
}
}
3.2 翻译管理工具
typescript
// utils/translation-manager.ts
export class TranslationManager {
private translations: Map<string, any> = new Map()
private missingKeys: Set<string> = new Set()
private config: TranslationConfig
constructor(config: TranslationConfig) {
this.config = config
}
// 加载翻译文件
async loadTranslations(locale: string): Promise<void> {
try {
const response = await fetch(`/api/translations/${locale}`)
const translations = await response.json()
this.translations.set(locale, translations)
} catch (error) {
console.error(`Failed to load translations for ${locale}:`, error)
throw error
}
}
// 获取翻译
getTranslation(locale: string, key: string, params?: Record<string, any>): string {
const translations = this.translations.get(locale)
if (!translations) {
this.missingKeys.add(`${locale}:${key}`)
return key
}
const value = this.getNestedValue(translations, key)
if (value === undefined) {
this.missingKeys.add(`${locale}:${key}`)
return key
}
return this.interpolate(value, params)
}
// 批量更新翻译
async updateTranslations(locale: string, updates: Record<string, any>): Promise<void> {
try {
await fetch(`/api/translations/${locale}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
})
// 更新本地缓存
const existing = this.translations.get(locale) || {}
this.translations.set(locale, { ...existing, ...updates })
} catch (error) {
console.error('Failed to update translations:', error)
throw error
}
}
// 导出缺失的翻译键
exportMissingKeys(): string[] {
return Array.from(this.missingKeys)
}
// 清除缺失键记录
clearMissingKeys(): void {
this.missingKeys.clear()
}
private getNestedValue(obj: any, path: string): any {
return path.split('.').reduce((current, key) => {
return current && current[key] !== undefined ? current[key] : undefined
}, obj)
}
private interpolate(template: string, params?: Record<string, any>): string {
if (!params) return template
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => {
return params[key] !== undefined ? String(params[key]) : match
})
}
}
interface TranslationConfig {
apiEndpoint: string
cacheEnabled: boolean
fallbackLocale: string
}
4. 动态配置系统
4.1 配置管理
typescript
// utils/config-manager.ts
export class I18nConfigManager {
private config: I18nDynamicConfig
private listeners: Set<(config: I18nDynamicConfig) => void> = new Set()
constructor(initialConfig: I18nDynamicConfig) {
this.config = initialConfig
}
// 获取配置
getConfig(): I18nDynamicConfig {
return { ...this.config }
}
// 更新配置
updateConfig(updates: Partial<I18nDynamicConfig>): void {
this.config = { ...this.config, ...updates }
this.notifyListeners()
}
// 从服务器加载配置
async loadFromServer(): Promise<void> {
try {
const response = await fetch('/api/i18n/config')
const serverConfig = await response.json()
this.updateConfig(serverConfig)
} catch (error) {
console.error('Failed to load i18n config from server:', error)
}
}
// 保存配置到服务器
async saveToServer(): Promise<void> {
try {
await fetch('/api/i18n/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.config)
})
} catch (error) {
console.error('Failed to save i18n config to server:', error)
throw error
}
}
// 添加配置变更监听器
addListener(listener: (config: I18nDynamicConfig) => void): void {
this.listeners.add(listener)
}
// 移除配置变更监听器
removeListener(listener: (config: I18nDynamicConfig) => void): void {
this.listeners.delete(listener)
}
// 通知监听器
private notifyListeners(): void {
this.listeners.forEach(listener => {
try {
listener(this.getConfig())
} catch (error) {
console.error('Error in config listener:', error)
}
})
}
}
interface I18nDynamicConfig {
defaultLocale: string
supportedLocales: string[]
autoDetect: boolean
fallbackEnabled: boolean
cacheEnabled: boolean
lazyLoading: boolean
rtlSupport: boolean
dateFormat: Record<string, string>
numberFormat: Record<string, any>
currencyFormat: Record<string, any>
}
4. 实践练习
多语言系统搭建:
- 实现完整的多语言架构
- 配置动态语言切换
- 集成 Element Plus 国际化
高级功能实现:
- 实现语言自动检测
- 添加 RTL 布局支持
- 实现翻译资源懒加载
用户体验优化:
- 实现平滑的语言切换动画
- 添加语言切换快捷操作
- 优化移动端语言选择体验
5. 学习资源
- Vue I18n Documentation
- Element Plus Internationalization
- Web Internationalization Best Practices
- Unicode CLDR
6. 作业
- 实现完整的多语言切换系统
- 创建动态翻译资源管理工具
- 优化多语言用户体验
- 编写多语言最佳实践文档
总结
通过第60天的学习,我们深入掌握了:
- 多语言架构:设计了完整的国际化系统架构
- 动态切换:实现了多种语言切换方式和用户体验
- 资源管理:建立了高效的翻译资源管理系统
- 配置系统:实现了灵活的动态配置机制
这些技能将帮助我们构建真正国际化的 Element Plus 应用。