添加国际化支持:引入 vue-i18n,更新多个组件以支持语言切换和文本翻译

This commit is contained in:
jxxghp
2025-04-27 17:44:09 +08:00
parent 80ae853582
commit d0b3bc8137
27 changed files with 973 additions and 374 deletions

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { NavMenu } from '@/@layouts/types'
import { SystemNavMenus } from '@/router/menu'
import { getNavMenus } from '@/router/i18n-menu'
import { useUserStore } from '@/stores'
// 从 Store 中获取superuser信息
@@ -12,7 +12,7 @@ const appGroups = ref<Record<string, NavMenu[]>>({})
// 根据header属性对应用进行分类
function categorizeApps() {
// 获取可见的菜单项
const menus = SystemNavMenus.filter((item: NavMenu) => (!item.admin || superUser) && !item.footer)
const menus = getNavMenus().filter((item: NavMenu) => (!item.admin || superUser) && !item.footer)
// 按header属性分组
const groupedMenus: Record<string, NavMenu[]> = {}
@@ -80,16 +80,16 @@ onMounted(() => {
<style lang="scss" scoped>
.app-settings-container {
max-width: 960px;
margin: 0 auto;
margin-block: 0;
margin-inline: auto;
max-inline-size: 960px;
}
.settings-section-card {
overflow: hidden;
background-color: rgb(var(--v-theme-surface));
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
background-color: rgb(var(--v-theme-surface));
box-shadow: 0 2px 8px rgba(0, 0, 0, 8%);
}
.settings-list {
@@ -97,11 +97,12 @@ onMounted(() => {
}
.settings-list-item {
padding: 8px 12px;
padding-block: 8px;
padding-inline: 12px;
transition: background-color 0.2s;
&:not(:last-child) {
border-bottom: 1px solid rgba(var(--v-border-color), 0.12);
border-block-end: 1px solid rgba(var(--v-border-color), 0.12);
}
&:hover {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { DiscoverTabs } from '@/router/menu'
import { getDiscoverTabs } from '@/router/i18n-menu'
import draggable from 'vuedraggable'
import TheMovieDbView from '@/views/discover/TheMovieDbView.vue'
import DoubanView from '@/views/discover/DoubanView.vue'
@@ -7,7 +7,6 @@ import BangumiView from '@/views/discover/BangumiView.vue'
import ExtraSourceView from '@/views/discover/ExtraSourceView.vue'
import { DiscoverSource } from '@/api/types'
import api from '@/api'
import { or } from '@vueuse/math'
const activeTab = ref('')
@@ -24,6 +23,7 @@ const discoverTabs = ref<DiscoverSource[]>([])
const discoverTabItems = computed(() => {
return discoverTabs.value.map(item => ({
title: item.name,
tab: item.mediaid_prefix,
}))
})
@@ -35,7 +35,8 @@ const orderConfigDialog = ref(false)
// 初始化发现标签
function initDiscoverTabs() {
for (const tab of DiscoverTabs) {
const tabs = getDiscoverTabs()
for (const tab of tabs) {
discoverTabs.value.push({
name: tab.name,
mediaid_prefix: tab.tab,

View File

@@ -14,6 +14,7 @@ const downloaders = ref<DownloaderConf[]>([])
const downloaderItems = computed(() => {
return downloaders.value.map(item => ({
title: item.name,
tab: item.name,
}))
})

View File

@@ -8,9 +8,10 @@ import api from '@/api'
import router from '@/router'
import logo from '@images/logo.png'
import { useTheme } from 'vuetify'
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
import { urlBase64ToUint8Array } from '@/@core/utils/navigator'
import { saveLocalTheme } from '@/@core/utils/theme'
import { useI18n } from 'vue-i18n'
import { SUPPORTED_LOCALES, SupportedLocale } from '@/locales/types'
import { getCurrentLocale, setI18nLanguage } from '@/plugins/i18n'
// 主题
const { global: globalTheme } = useTheme()
@@ -18,6 +19,8 @@ const { global: globalTheme } = useTheme()
const authStore = useAuthStore()
//用户 Store
const userStore = useUserStore()
// 国际化
const { t } = useI18n()
// 表单
const form = ref({
@@ -41,6 +44,21 @@ const isOTP = ref(false)
// 用户名称输入框
const usernameInput = ref()
// 语言选择菜单
const langMenu = ref(false)
// 当前语言
const currentLocale = ref(getCurrentLocale())
// 可用的语言列表
const locales = Object.values(SUPPORTED_LOCALES)
// 切换语言
async function switchLanguage(locale: SupportedLocale) {
await setI18nLanguage(locale)
currentLocale.value = locale
langMenu.value = false
}
// 查询是否开启双重验证
const fetchOTP = debounce(async () => {
const userid = usernameInput.value?.value
@@ -58,24 +76,6 @@ const fetchOTP = debounce(async () => {
})
}, 500)
// 获取用户主题配置
async function fetchThemeConfig() {
const response = await api.get('/user/config/Layout')
if (response && response.data && response.data.value) {
return response.data.value?.theme
}
return null
}
// 生效主题
async function setTheme() {
let themeValue = (await fetchThemeConfig()) || localStorage.getItem('theme') || 'light'
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
// 存储主题到本地
saveLocalTheme(themeValue, globalTheme)
}
// 订阅推送通知
async function subscribeForPushNotifications() {
if ('serviceWorker' in navigator && 'PushManager' in window) {
@@ -103,8 +103,6 @@ async function subscribeForPushNotifications() {
// 登录后处理
async function afterLogin(superuser: boolean) {
// 生效主题配置
await setTheme()
// 跳转到首页或回原始页面
router.push(authStore.originalPath ?? '/')
// 订阅推送通知
@@ -156,11 +154,11 @@ function login() {
})
.catch((error: any) => {
// 登录失败,显示错误提示
if (!error.response) errorMessage.value = '登录失败,请检查网络连接!'
else if (error.response.status === 401) errorMessage.value = '登录失败,请检查用户名、密码或双重验证是否正确!'
else if (error.response.status === 403) errorMessage.value = '登录失败,您没有权限访问!'
else if (error.response.status === 500) errorMessage.value = '登录失败,服务器错误!'
else errorMessage.value = `登录失败 ${error.response.status}请检查用户名、密码或双重验证码是否正确!`
if (!error.response) errorMessage.value = t('login.networkError')
else if (error.response.status === 401) errorMessage.value = t('login.authFailure')
else if (error.response.status === 403) errorMessage.value = t('login.permissionDenied')
else if (error.response.status === 500) errorMessage.value = t('login.serverError')
else errorMessage.value = `${t('login.loginFailed')} ${error.response.status}${t('login.checkCredentials')}`
})
}
@@ -195,6 +193,35 @@ onMounted(async () => {
</div>
</template>
<VCardTitle class="font-weight-bold text-2xl text-uppercase"> MoviePilot </VCardTitle>
<!-- 语言切换按钮 -->
<template #append>
<VMenu v-model="langMenu" :close-on-content-click="false">
<template #activator="{ props }">
<VBtn variant="text" size="small" v-bind="props" class="lang-switch-btn">
<span v-if="SUPPORTED_LOCALES[currentLocale].flag">{{ SUPPORTED_LOCALES[currentLocale].flag }}</span>
<VIcon v-else icon="mdi-translate" />
<span class="ms-1">{{ SUPPORTED_LOCALES[currentLocale].title }}</span>
</VBtn>
</template>
<VCard min-width="180">
<VList>
<VListItem
v-for="locale in locales"
:key="locale.name"
:value="locale.name"
@click="switchLanguage(locale.name as SupportedLocale)"
>
<template #prepend>
<span v-if="locale.flag" class="mr-2">{{ locale.flag }}</span>
<VIcon v-else icon="mdi-translate" size="small" />
</template>
<VListItemTitle>{{ locale.title }}</VListItemTitle>
</VListItem>
</VList>
</VCard>
</VMenu>
</template>
</VCardItem>
<VCardText>
<VForm ref="refForm" autocomplete="on" @submit.prevent="() => {}">
@@ -204,7 +231,7 @@ onMounted(async () => {
<VTextField
ref="usernameInput"
v-model="form.username"
label="用户名"
:label="t('login.username')"
type="text"
name="username"
autocomplete="username"
@@ -216,7 +243,7 @@ onMounted(async () => {
<VCol cols="12">
<VTextField
v-model="form.password"
label="密码"
:label="t('login.password')"
:type="isPasswordVisible ? 'text' : 'password'"
name="current-password"
autocomplete="current-password"
@@ -226,15 +253,15 @@ onMounted(async () => {
/>
</VCol>
<VCol cols="12">
<VTextField v-if="isOTP" v-model="form.otp_password" label="双重验证码" type="input" />
<VTextField v-if="isOTP" v-model="form.otp_password" :label="t('login.otpCode')" type="input" />
<!-- remember me checkbox -->
<div class="d-flex align-center justify-space-between flex-wrap">
<VCheckbox v-model="form.remember" label="保持登录" required />
<VCheckbox v-model="form.remember" :label="t('login.stayLoggedIn')" required />
</div>
</VCol>
<VCol cols="12">
<!-- login button -->
<VBtn block type="submit" @click="login" prepend-icon="mdi-login"> 登录 </VBtn>
<VBtn block type="submit" @click="login" prepend-icon="mdi-login"> {{ t('login.login') }} </VBtn>
<VAlert v-if="errorMessage" type="error" variant="tonal" class="mt-3">
{{ errorMessage }}
</VAlert>
@@ -258,4 +285,10 @@ onMounted(async () => {
overflow: hidden;
block-size: auto;
}
.lang-switch-btn {
position: absolute;
inset-block-start: 8px;
inset-inline-end: 8px;
}
</style>

View File

@@ -160,27 +160,27 @@ const categoryItems: Record<string, string>[] = [
{
title: '全部',
icon: 'mdi-filmstrip-box-multiple',
key: 'all',
tab: '全部',
},
{
title: '电影',
icon: 'mdi-movie',
key: 'movie',
tab: '电影',
},
{
title: '电视剧',
icon: 'mdi-television-classic',
key: 'tv',
tab: '电视剧',
},
{
title: '动漫',
icon: 'mdi-animation',
key: 'anime',
tab: '动漫',
},
{
title: '榜单',
icon: 'mdi-trophy',
key: 'rank',
tab: '榜单',
},
]

View File

@@ -11,23 +11,20 @@ import AccountSettingSystem from '@/views/setting/AccountSettingSystem.vue'
import AccountSettingScheduler from '@/views/setting/AccountSettingScheduler.vue'
import AccountSettingDirectory from '@/views/setting/AccountSettingDirectory.vue'
import AccountSettingRule from '@/views/setting/AccountSettingRule.vue'
import { SettingTabs } from '@/router/menu'
import { getSettingTabs } from '@/router/i18n-menu'
const route = useRoute()
const activeTab = ref(route.query.tab)
function jumpTab(tab: string) {
router.push('/setting?tab=' + tab)
}
const settingTabs = computed(() => getSettingTabs())
</script>
<template>
<div>
<VHeaderTab :items="SettingTabs" v-model="activeTab" />
<VHeaderTab :items="settingTabs" v-model="activeTab" />
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
<!-- 系统 -->
<VWindowItem value="系统">
<VWindowItem value="system">
<transition name="fade-slide" appear>
<div>
<AccountSettingSystem />
@@ -36,7 +33,7 @@ function jumpTab(tab: string) {
</VWindowItem>
<!-- 目录 -->
<VWindowItem value="存储 & 目录">
<VWindowItem value="directory">
<transition name="fade-slide" appear>
<div>
<AccountSettingDirectory />
@@ -45,7 +42,7 @@ function jumpTab(tab: string) {
</VWindowItem>
<!-- 站点 -->
<VWindowItem value="站点">
<VWindowItem value="site">
<transition name="fade-slide" appear>
<div>
<AccountSettingSite />
@@ -54,7 +51,7 @@ function jumpTab(tab: string) {
</VWindowItem>
<!-- 规则 -->
<VWindowItem value="规则">
<VWindowItem value="rule">
<transition name="fade-slide" appear>
<div>
<AccountSettingRule />
@@ -63,7 +60,7 @@ function jumpTab(tab: string) {
</VWindowItem>
<!-- 搜索 -->
<VWindowItem value="搜索 & 下载">
<VWindowItem value="search">
<transition name="fade-slide" appear>
<div>
<AccountSettingSearch />
@@ -72,7 +69,7 @@ function jumpTab(tab: string) {
</VWindowItem>
<!-- 订阅 -->
<VWindowItem value="订阅">
<VWindowItem value="subscribe">
<transition name="fade-slide" appear>
<div>
<AccountSettingSubscribe />
@@ -81,7 +78,7 @@ function jumpTab(tab: string) {
</VWindowItem>
<!-- 服务 -->
<VWindowItem value="服务">
<VWindowItem value="scheduler">
<transition name="fade-slide" appear>
<div>
<AccountSettingScheduler />
@@ -90,7 +87,7 @@ function jumpTab(tab: string) {
</VWindowItem>
<!-- 通知 -->
<VWindowItem value="通知">
<VWindowItem value="notification">
<transition name="fade-slide" appear>
<div>
<AccountSettingNotification />
@@ -99,7 +96,7 @@ function jumpTab(tab: string) {
</VWindowItem>
<!-- 词表 -->
<VWindowItem value="词表">
<VWindowItem value="words">
<transition name="fade-slide" appear>
<div>
<AccountSettingWords />
@@ -108,7 +105,7 @@ function jumpTab(tab: string) {
</VWindowItem>
<!-- 关于 -->
<VWindowItem value="关于">
<VWindowItem value="about">
<transition name="fade-slide" appear>
<div>
<AccountSettingAbout />

View File

@@ -4,7 +4,7 @@ import SubscribePopularView from '@/views/subscribe/SubscribePopularView.vue'
import SubscribeShareView from '@/views/subscribe/SubscribeShareView.vue'
import SubscribeEditDialog from '@/components/dialog/SubscribeEditDialog.vue'
import { SubscribeMovieTabs, SubscribeTvTabs } from '@/router/menu'
import { getSubscribeMovieTabs, getSubscribeTvTabs } from '@/router/i18n-menu'
const route = useRoute()
@@ -13,6 +13,15 @@ const subId = ref(route.query.id as string)
const activeTab = ref(route.query.tab)
const shareViewKey = ref(0)
// 获取标签页
const subscribeTabs = computed(() => {
if (subType === '电影') {
return getSubscribeMovieTabs()
} else {
return getSubscribeTvTabs()
}
})
// 默认订阅设置弹窗
const subscribeEditDialog = ref(false)
@@ -37,10 +46,10 @@ const searchShares = () => {
<template>
<div>
<VHeaderTab :items="subType == '电影' ? SubscribeMovieTabs : SubscribeTvTabs" v-model="activeTab">
<VHeaderTab :items="subscribeTabs" v-model="activeTab">
<template #append>
<VMenu
v-if="activeTab === '我的订阅'"
v-if="activeTab === 'mysub'"
v-model="filterSubscribeDialog"
width="20rem"
:close-on-content-click="false"
@@ -70,7 +79,7 @@ const searchShares = () => {
</VCard>
</VMenu>
<VMenu
v-if="activeTab === '订阅分享'"
v-if="activeTab === 'share'"
v-model="searchShareDialog"
width="25rem"
:close-on-content-click="false"
@@ -104,7 +113,7 @@ const searchShares = () => {
</VCard>
</VMenu>
<VBtn
v-if="activeTab === '我的订阅'"
v-if="activeTab === 'mysub'"
icon="mdi-clipboard-edit-outline"
variant="text"
color="gray"
@@ -116,21 +125,21 @@ const searchShares = () => {
</VHeaderTab>
<VWindow v-model="activeTab" class="disable-tab-transition" :touch="false">
<VWindowItem value="我的订阅">
<VWindowItem value="mysub">
<transition name="fade-slide" appear>
<div>
<SubscribeListView :type="subType" :subid="subId" :keyword="subscribeFilter" />
</div>
</transition>
</VWindowItem>
<VWindowItem value="热门订阅">
<VWindowItem value="popular">
<transition name="fade-slide" appear>
<div>
<SubscribePopularView :type="subType" />
</div>
</transition>
</VWindowItem>
<VWindowItem value="订阅分享">
<VWindowItem value="share">
<transition name="fade-slide" appear>
<div>
<SubscribeShareView :keyword="shareKeyword" :key="shareViewKey" />