feat(i18n): add PuroLocaleSwitcher for portal pages

This commit is contained in:
mini
2026-04-20 21:24:51 +08:00
parent 49ee2cba8a
commit e711a20373

View File

@@ -0,0 +1,150 @@
<template>
<div class="puro-locale" ref="dropdownRef">
<button
class="puro-locale-btn"
type="button"
:disabled="switching"
:aria-expanded="isOpen"
:title="currentLocale?.name"
@click="toggleDropdown"
>
<span class="puro-locale-code">{{ currentLocale?.code.toUpperCase() }}</span>
<svg class="puro-locale-chev" :class="{ open: isOpen }" viewBox="0 0 12 12" width="10" height="10" aria-hidden="true">
<path d="M2 4l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<transition name="puro-locale-pop">
<div v-if="isOpen" class="puro-locale-menu" role="listbox">
<button
v-for="option in availableLocales"
:key="option.code"
type="button"
role="option"
:aria-selected="option.code === currentLocaleCode"
class="puro-locale-option"
:class="{ active: option.code === currentLocaleCode }"
:disabled="switching"
@click="selectLocale(option.code)"
>
<span class="puro-locale-option-code">{{ option.code.toUpperCase() }}</span>
<span class="puro-locale-option-name">{{ option.name }}</span>
<span v-if="option.code === currentLocaleCode" class="puro-locale-option-dot" aria-hidden="true"></span>
</button>
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { setLocale, availableLocales } from '@/i18n'
const { locale } = useI18n()
const isOpen = ref(false)
const dropdownRef = ref<HTMLElement | null>(null)
const switching = ref(false)
const currentLocaleCode = computed(() => locale.value)
const currentLocale = computed(() => availableLocales.find((l) => l.code === locale.value))
function toggleDropdown() {
isOpen.value = !isOpen.value
}
async function selectLocale(code: string) {
if (switching.value || code === currentLocaleCode.value) {
isOpen.value = false
return
}
switching.value = true
try {
await setLocale(code)
isOpen.value = false
} finally {
switching.value = false
}
}
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {
isOpen.value = false
}
}
onMounted(() => document.addEventListener('click', handleClickOutside))
onBeforeUnmount(() => document.removeEventListener('click', handleClickOutside))
</script>
<style scoped>
.puro-locale { position: relative; display: inline-flex; }
.puro-locale-btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 10px;
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 12px; font-weight: 500;
color: var(--text-1, #cbd5e1);
background: rgba(2, 6, 23, 0.4);
border: 1px solid var(--border, rgba(148,163,184,0.18));
border-radius: 8px;
cursor: pointer;
transition: border-color .15s, color .15s, background .15s;
}
.puro-locale-btn:hover:not(:disabled) {
border-color: var(--border-2, rgba(148,163,184,0.32));
color: var(--cyan, #22d3ee);
}
.puro-locale-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.puro-locale-code { letter-spacing: 0.08em; }
.puro-locale-chev { color: var(--text-3, #64748b); transition: transform .15s; }
.puro-locale-chev.open { transform: rotate(180deg); color: var(--cyan, #22d3ee); }
.puro-locale-menu {
position: absolute; top: calc(100% + 6px); right: 0;
min-width: 140px;
background: rgba(15, 23, 42, 0.95);
border: 1px solid var(--border, rgba(148,163,184,0.18));
border-radius: 10px;
padding: 4px;
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.5);
backdrop-filter: blur(8px);
z-index: 50;
}
.puro-locale-option {
width: 100%;
display: flex; align-items: center; gap: 10px;
padding: 8px 10px;
background: transparent; border: none; border-radius: 6px;
font-size: 13px; color: var(--text-1, #cbd5e1);
cursor: pointer;
text-align: left;
transition: background .12s, color .12s;
}
.puro-locale-option:hover:not(:disabled) {
background: rgba(34, 211, 238, 0.08);
color: var(--text-0, #f8fafc);
}
.puro-locale-option.active {
color: var(--cyan, #22d3ee);
}
.puro-locale-option-code {
font-family: var(--font-mono);
font-size: 11px; letter-spacing: 0.08em;
color: var(--text-3, #64748b);
}
.puro-locale-option.active .puro-locale-option-code { color: var(--cyan, #22d3ee); }
.puro-locale-option-name { flex: 1; }
.puro-locale-option-dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--cyan, #22d3ee);
box-shadow: 0 0 0 3px rgba(34,211,238,0.2);
}
.puro-locale-pop-enter-active,
.puro-locale-pop-leave-active { transition: opacity .12s, transform .12s; }
.puro-locale-pop-enter-from,
.puro-locale-pop-leave-to { opacity: 0; transform: translateY(-4px) scale(0.97); }
</style>