Files
cloudflare_temp_email/frontend/src/views/Header.vue
bhwa233 eb62c37e02 feat(i18n): enhance locale handling and routing (#996)
* feat(i18n): enhance locale handling and routing

- Implemented dynamic locale aliases in router configuration.
- Added support for preferred locale storage in global state.
- Improved locale resolution logic in router beforeEach guard.
- Created utility functions for locale management and path manipulation.
- Added tests for locale matching and message extraction.
- Updated Header component to allow language selection.
- Refactored getRouterPathWithLang to utilize new locale utilities.
- Updated Vite configuration to support aliasing for vue-i18n.
- Bumped version numbers across various packages to 1.9.0.

* feat(i18n): update version to 1.8.0 and enhance locale handling

- Updated version numbers across all package.json files to 1.8.0.
- Enhanced locale handling in App.vue by centralizing locale configurations.
- Improved Turnstile component to support dynamic language rendering.
- Refactored i18n utilities to include initial locale setup and empty locale messages.
- Updated i18n.ts to utilize the new locale management structure.
- Added naive-locale.ts for better integration with Naive UI's locale handling.
- Adjusted Header.vue to streamline language selection and locale changes.
- Fixed translations in multiple locale files for consistency and accuracy.

* fix(i18n): address review feedback

* feat(i18n): update default locale to English and enhance language handling in components

* fix(i18n): switch locale selector to dropdown

* docs: add topbar language and github order design spec

* fix(i18n): 修复 Header 语言切换器相关问题,恢复为独立控件并调整样式

* Refactor locale handling in router and add locale-guard utility functions

- Improved locale resolution logic in router by introducing utility functions for better readability and maintainability.
- Added `locale-guard.js` to encapsulate locale-related functions such as getting route locale, resolving locale for navigation, and applying locale navigation state.
- Updated JWT synchronization logic to streamline the handling of JWT from query parameters.
- Modified i18n messages test to check for coverage of registered locale message keys instead of extracting English source messages.

* 删除顶部栏语言和GitHub顺序设计文档

* fix: 修复前端设置初始化时未返回 domains 数组导致的 undefined 错误

* refactor(i18n): consolidate locale infrastructure

* fix(i18n): stabilize locale route switching

* fix(i18n): persist default locale selection

* fix(i18n): 修复前端设置初始化时未返回 domains 数组导致的 undefined 错误,统一按空数组兜底处理
feat(i18n): 添加 locale 别名处理,支持默认语言的重定向
test(i18n): 增加对默认语言别名重定向的测试用例

* refactor: replace useAppI18n with useScopedI18n in multiple components for improved localization management

* fix(tests): 移除不必要的 URL 断言以简化 Passkey 测试

* fix(i18n): 更新语言切换逻辑,确保使用当前语言设置进行路由导航

* fix(i18n): 强制路由切换以确保语言切换后正确导航

* refactor(i18n): 优化消息注册和路由本地化逻辑,移除冗余代码

* refactor(i18n): 拆分 API 文件以优化路由管理,更新语言处理逻辑

* fix: align i18n release notes and frontend test script
2026-04-25 13:46:26 +08:00

394 lines
12 KiB
Vue

<script setup>
import { ref, h, computed, onMounted } from 'vue'
import { useScopedI18n } from '@/i18n/app'
import { useHead } from '@unhead/vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import { useIsMobile } from '../utils/composables'
import {
DarkModeFilled, LightModeFilled, MenuFilled,
AdminPanelSettingsFilled, MonitorHeartFilled,
KeyboardArrowDownOutlined
} from '@vicons/material'
import { GithubAlt, Language, User, Home } from '@vicons/fa'
import { useGlobalState } from '../store'
import { api } from '../api'
import { getRouterPathWithLang, hashPassword } from '../utils'
import { DEFAULT_LOCALE, isSupportedLocale, replaceLocaleInFullPath } from '../i18n/utils'
import { getLocaleLabel, SUPPORTED_LOCALES } from '../i18n/locale-registry'
import Turnstile from '../components/Turnstile.vue'
import { NButton, NIcon } from 'naive-ui'
const message = useMessage()
const notification = useNotification()
const {
toggleDark, isDark, isTelegram, showAdminPage,
showAuth, auth, loading, openSettings, preferredLocale, userSettings
} = useGlobalState()
const route = useRoute()
const router = useRouter()
const isMobile = useIsMobile()
const showMobileMenu = ref(false)
const menuValue = computed(() => {
if (route.path.includes("user")) return "user";
if (route.path.includes("admin")) return "admin";
return "home";
});
const cfToken = ref('')
const turnstileRef = ref(null)
const authFunc = async () => {
try {
await api.fetch('/open_api/site_login', {
method: 'POST',
body: JSON.stringify({
password: await hashPassword(auth.value),
cf_token: cfToken.value
})
});
location.reload()
} catch (error) {
message.error(error.message || "error");
turnstileRef.value?.refresh?.();
}
}
const languageOptions = SUPPORTED_LOCALES.map((locale) => ({
label: getLocaleLabel(locale),
value: locale,
key: locale,
}))
const currentLocaleLabel = computed(() => {
return languageOptions.find(opt => opt.value === locale.value)?.label || locale.value;
});
const { t, locale } = useScopedI18n('views.Header')
const changeLocale = async (lang) => {
if (!isSupportedLocale(lang)) {
return;
}
const currentFullPath = route.fullPath;
const targetFullPath = replaceLocaleInFullPath(currentFullPath, lang);
if (lang === locale.value && targetFullPath === currentFullPath) {
showMobileMenu.value = false;
return;
}
if (lang === DEFAULT_LOCALE) {
preferredLocale.value = DEFAULT_LOCALE;
}
let localeSwitched = false;
try {
await router.push({ path: targetFullPath, force: true });
localeSwitched = router.currentRoute.value.fullPath === targetFullPath;
if (!localeSwitched) {
await router.replace({ path: targetFullPath, force: true });
localeSwitched = router.currentRoute.value.fullPath === targetFullPath;
}
} catch (error) {
console.error('Failed to switch locale', error);
} finally {
showMobileMenu.value = false;
}
if (localeSwitched) preferredLocale.value = lang;
}
const version = import.meta.env.PACKAGE_VERSION ? `v${import.meta.env.PACKAGE_VERSION}` : "";
const menuOptions = computed(() => [
{
label: () => h(NButton,
{
text: true,
size: "small",
type: menuValue.value == "home" ? "primary" : "default",
style: "width: 100%",
onClick: async () => {
await router.push(getRouterPathWithLang('/', locale.value));
showMobileMenu.value = false;
}
},
{
default: () => t('home'),
icon: () => h(NIcon, { component: Home })
}),
key: "home"
},
{
label: () => h(
NButton,
{
text: true,
size: "small",
type: menuValue.value == "user" ? "primary" : "default",
style: "width: 100%",
onClick: async () => {
await router.push(getRouterPathWithLang("/user", locale.value));
showMobileMenu.value = false;
}
},
{
default: () => t('user'),
icon: () => h(NIcon, { component: User }),
}
),
key: "user",
show: !isTelegram.value
},
{
label: () => h(
NButton,
{
text: true,
size: "small",
type: menuValue.value == "admin" ? "primary" : "default",
style: "width: 100%",
onClick: async () => {
loading.value = true;
await router.push(getRouterPathWithLang('/admin', locale.value));
loading.value = false;
showMobileMenu.value = false;
}
},
{
default: () => "Admin",
icon: () => h(NIcon, { component: AdminPanelSettingsFilled }),
}
),
show: showAdminPage.value,
key: "admin"
},
{
label: () => h(
NButton,
{
text: true,
size: "small",
style: "width: 100%",
onClick: () => { toggleDark(); showMobileMenu.value = false; }
},
{
default: () => isDark.value ? t('light') : t('dark'),
icon: () => h(
NIcon, { component: isDark.value ? LightModeFilled : DarkModeFilled }
)
}
),
key: "theme"
},
{
label: () => h(
NButton,
{
text: true,
size: "small",
style: "width: 100%",
tag: "a",
target: "_blank",
href: openSettings.value?.statusUrl,
},
{
default: () => t('status'),
icon: () => h(NIcon, { component: MonitorHeartFilled })
}
),
show: !!openSettings.value?.statusUrl,
key: "status"
}
]);
useHead({
title: () => openSettings.value.title || t('title'),
meta: [
{ name: "description", content: openSettings.value.description || t('title') },
]
});
const logoClickCount = ref(0);
const logoClick = async () => {
if (route.path.includes("admin")) {
logoClickCount.value = 0;
return;
}
if (logoClickCount.value >= 5) {
logoClickCount.value = 0;
message.info("Change to admin Page");
loading.value = true;
await router.push(getRouterPathWithLang('/admin', locale.value));
loading.value = false;
} else {
logoClickCount.value++;
}
if (logoClickCount.value > 0) {
message.info(`Click ${5 - logoClickCount.value + 1} times to enter the admin page`);
}
}
onMounted(async () => {
await api.getOpenSettings(message, notification);
// make sure user_id is fetched
if (!userSettings.value.user_id) await api.getUserSettings(message);
});
</script>
<template>
<div>
<n-page-header>
<template #title>
<h3>{{ openSettings.title || t('title') }}</h3>
</template>
<template #avatar>
<div @click="logoClick">
<n-avatar style="margin-left: 10px;" src="/logo.png" />
</div>
</template>
<template #extra>
<n-space align="center" class="header-extra">
<n-menu v-if="!isMobile" mode="horizontal" :options="menuOptions" responsive />
<n-button v-else :text="true" @click="showMobileMenu = !showMobileMenu" style="margin-right: 10px;">
<template #icon>
<n-icon :component="MenuFilled" />
</template>
{{ t('menu') }}
</n-button>
<n-dropdown :options="languageOptions" @select="changeLocale" trigger="click" class="header-locale-dropdown">
<n-button text size="small" class="header-locale-button" style="padding: 0 10px;">
<template #icon>
<n-icon :component="Language" />
</template>
{{ currentLocaleLabel }}
<n-icon :component="KeyboardArrowDownOutlined" style="margin-left: 4px;" />
</n-button>
</n-dropdown>
<n-button
v-if="openSettings.showGithub"
text
size="small"
class="header-version-button"
tag="a"
target="_blank"
href="https://github.com/dreamhunter2333/cloudflare_temp_email"
>
<template #icon>
<n-icon :component="GithubAlt" />
</template>
{{ version || 'Github' }}
</n-button>
</n-space>
</template>
</n-page-header>
<n-drawer v-model:show="showMobileMenu" placement="top" style="height: 100vh;">
<n-drawer-content :title="t('menu')" closable>
<n-menu :options="menuOptions" />
<n-dropdown :options="languageOptions" @select="changeLocale" trigger="click" class="header-locale-dropdown">
<n-button text class="header-locale-button" style="margin-top: 12px;">
<template #icon>
<n-icon :component="Language" />
</template>
{{ currentLocaleLabel }}
<n-icon :component="KeyboardArrowDownOutlined" style="margin-left: 4px;" />
</n-button>
</n-dropdown>
<n-button
v-if="openSettings.showGithub"
text
class="header-version-button"
style="margin-top: 12px;"
tag="a"
target="_blank"
href="https://github.com/dreamhunter2333/cloudflare_temp_email"
>
<template #icon>
<n-icon :component="GithubAlt" />
</template>
{{ version || 'Github' }}
</n-button>
</n-drawer-content>
</n-drawer>
<n-modal v-model:show="showAuth" :closable="false" :closeOnEsc="false" :maskClosable="false" preset="dialog"
:title="t('accessHeader')">
<p>{{ t('accessTip') }}</p>
<n-input v-model:value="auth" type="password" show-password-on="click" />
<Turnstile ref="turnstileRef" v-if="openSettings.enableGlobalTurnstileCheck" v-model:value="cfToken" />
<template #action>
<n-button :loading="loading" @click="authFunc" type="primary">
{{ t('ok') }}
</n-button>
</template>
</n-modal>
</div>
</template>
<style scoped>
.n-layout-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.header-extra {
align-items: center;
}
.header-extra :deep(.n-space-item) {
display: flex;
align-items: center;
}
.header-locale-button {
display: inline-flex;
align-items: center;
}
.header-locale-button :deep(.n-button__content) {
display: inline-flex;
align-items: center;
}
.header-locale-button :deep(.n-icon) {
display: inline-flex;
align-items: center;
}
.header-version-button {
display: inline-flex;
align-items: center;
}
.header-version-button :deep(.n-button__content) {
display: inline-flex;
align-items: center;
}
.n-alert {
margin-top: 10px;
margin-bottom: 10px;
text-align: center;
}
.n-card {
margin-top: 10px;
}
.center {
display: flex;
text-align: left;
place-items: center;
justify-content: center;
margin: 20px;
}
.n-form .n-button {
margin-top: 10px;
}
</style>