添加国际化支持:引入 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

1
components.d.ts vendored
View File

@@ -11,6 +11,7 @@ declare module 'vue' {
ErrorHeader: typeof import('./src/@core/components/ErrorHeader.vue')['default']
ExistIcon: typeof import('./src/@core/components/ExistIcon.vue')['default']
LoadingBanner: typeof import('./src/@core/components/LoadingBanner.vue')['default']
LocaleSwitcher: typeof import('./src/@core/components/LocaleSwitcher.vue')['default']
MoreBtn: typeof import('./src/@core/components/MoreBtn.vue')['default']
PageContentTitle: typeof import('./src/@core/components/PageContentTitle.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import { SUPPORTED_LOCALES, SupportedLocale } from '@/locales/types'
import { setI18nLanguage, getCurrentLocale } from '@/plugins/i18n'
// 当前语言
const currentLocale = ref<SupportedLocale>(getCurrentLocale())
// 支持的语言列表
const locales = computed(() => {
return Object.entries(SUPPORTED_LOCALES).map(([key, locale]) => ({
value: key as SupportedLocale,
title: locale.title,
flag: locale.flag,
icon: `flag-${key.split('-')[0]}`,
}))
})
// 切换语言
async function changeLocale(locale: SupportedLocale) {
try {
await setI18nLanguage(locale)
currentLocale.value = locale
// 刷新页面
window.location.reload()
} catch (error) {
console.error(error)
}
}
// 获取当前语言图标
const getCurrentIcon = computed(() => {
const locale = locales.value.find(l => l.value === currentLocale.value)
return locale?.flag || '🌐'
})
</script>
<template>
<VMenu class="locale-menu" scrim>
<template v-slot:activator="{ props }">
<IconBtn v-bind="props">
<span class="text-xl">{{ getCurrentIcon }}</span>
</IconBtn>
</template>
<VList>
<div class="px-2">
<VListItem
v-for="locale in locales"
:key="locale.value"
@click="changeLocale(locale.value)"
:active="currentLocale === locale.value"
class="mb-1"
>
<template #prepend>
<span class="text-xl me-2">{{ locale.flag }}</span>
</template>
<VListItemTitle>{{ locale.title }}</VListItemTitle>
<template #append v-if="currentLocale === locale.value">
<VIcon icon="mdi-check" color="primary" size="small" />
</template>
</VListItem>
</div>
</VList>
</VMenu>
</template>

View File

@@ -6,6 +6,7 @@ import api from '@/api'
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
import { useToast } from 'vue-toast-notification'
import { saveLocalTheme } from '../utils/theme'
import { useI18n } from 'vue-i18n'
// 显示器宽度
const display = useDisplay()
@@ -27,6 +28,8 @@ const getNextThemeName = () => {
const $toast = useToast()
const { t } = useI18n()
// 自定义CSS弹窗
const cssDialog = ref(false)
@@ -64,14 +67,6 @@ function changeTheme(theme: string) {
}
}
// 是否有滚动条
function hasScrollbar(el?: Element | null) {
if (!el || el.nodeType !== Node.ELEMENT_NODE) return false
const style = window.getComputedStyle(el)
return style.overflowY === 'scroll' || (style.overflowY === 'auto' && el.scrollHeight > el.clientHeight)
}
// 监听系统主题变化
try {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateTheme)
@@ -158,7 +153,7 @@ onMounted(() => {
<template #prepend>
<VIcon icon="mdi-palette" />
</template>
<VListItemTitle>自定义主题</VListItemTitle>
<VListItemTitle>{{ t('theme.custom') }}</VListItemTitle>
</VListItem>
</div>
</VList>

View File

@@ -1,7 +1,7 @@
@use "@configured-variables" as variables;
// ————————————————————————————————————
//* ——— Perfect Scrollbar
// Perfect Scrollbar
// ————————————————————————————————————
.v-application.v-theme--dark {

View File

@@ -4,6 +4,12 @@ import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
import { ensureRenderComplete, removeEl } from './@core/utils/dom'
import api from '@/api'
import { useAuthStore } from '@/stores/auth'
import { useI18n } from 'vue-i18n'
import { getBrowserLocale, setI18nLanguage } from './plugins/i18n'
import { SupportedLocale } from './locales/types'
// 国际化
const { t } = useI18n()
// 生效主题
const { global: globalTheme } = useTheme()
@@ -11,6 +17,10 @@ let themeValue = localStorage.getItem('theme') || 'light'
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
// 生效语言
const localeValue = getBrowserLocale()
setI18nLanguage(localeValue as SupportedLocale)
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
@@ -67,7 +77,7 @@ function updateHtmlThemeAttribute(themeName: string) {
// 获取背景图片
async function fetchBackgroundImages() {
try {
backgroundImages.value = await api.get('/login/wallpapers')
backgroundImages.value = await api.get(`/login/${t('login.wallpapers')}`)
} catch (e) {
console.error(e)
}

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import api from '@/api'
import type { Site, Plugin, Subscribe } from '@/api/types'
import { SystemNavMenus, SettingTabs } from '@/router/menu'
import { getNavMenus, getSettingTabs } from '@/router/i18n-menu'
import { NavMenu } from '@/@layouts/types'
import { useUserStore } from '@/stores'
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
@@ -73,7 +73,7 @@ function loadRecentSearches() {
function getMenus(): NavMenu[] {
let menus: NavMenu[] = []
// 导航菜单
SystemNavMenus.forEach(
getNavMenus().forEach(
item =>
item &&
menus.push({
@@ -85,7 +85,7 @@ function getMenus(): NavMenu[] {
}),
)
// 设置标签页
SettingTabs.forEach(
getSettingTabs().forEach(
item =>
item &&
menus.push({

View File

@@ -3,18 +3,20 @@ import VerticalNavSectionTitle from '@/@layouts/components/VerticalNavSectionTit
import VerticalNavLayout from '@layouts/components/VerticalNavLayout.vue'
import VerticalNavLink from '@layouts/components/VerticalNavLink.vue'
import Footer from '@/layouts/components/Footer.vue'
import NavbarThemeSwitcher from '@/layouts/components/NavbarThemeSwitcher.vue'
import NavbarActions from '@/layouts/components/NavbarActions.vue'
import UserNofification from '@/layouts/components/UserNotification.vue'
import SearchBar from '@/layouts/components/SearchBar.vue'
import ShortcutBar from '@/layouts/components/ShortcutBar.vue'
import UserProfile from '@/layouts/components/UserProfile.vue'
import { useUserStore } from '@/stores'
import { SystemNavMenus } from '@/router/menu'
import { getNavMenus } from '@/router/i18n-menu'
import { NavMenu } from '@/@layouts/types'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
const display = useDisplay()
const appMode = inject('pwaMode')
const { t } = useI18n()
// 用户 Store
const userStore = useUserStore()
@@ -39,7 +41,9 @@ const systemMenus = ref<NavMenu[]>([])
// 根据分类获取菜单列表
const getMenuList = (header: string) => {
return SystemNavMenus.filter((item: NavMenu) => item.header === header && (superUser || !item.admin))
// 使用国际化菜单
const menus = getNavMenus()
return menus.filter((item: NavMenu) => item.header === header && (superUser || !item.admin))
}
// 返回上一页
@@ -49,11 +53,11 @@ function goBack() {
onMounted(() => {
// 获取菜单列表
startMenus.value = getMenuList('开始')
discoveryMenus.value = getMenuList('发现')
subscribeMenus.value = getMenuList('订阅')
organizeMenus.value = getMenuList('整理')
systemMenus.value = getMenuList('系统')
startMenus.value = getMenuList(t('menu.start'))
discoveryMenus.value = getMenuList(t('menu.discovery'))
subscribeMenus.value = getMenuList(t('menu.subscribe'))
organizeMenus.value = getMenuList(t('menu.organize'))
systemMenus.value = getMenuList(t('menu.system'))
})
</script>
@@ -74,10 +78,10 @@ onMounted(() => {
<SearchBar />
<!-- 👉 Spacer -->
<VSpacer />
<!-- 👉 Theme & Language -->
<NavbarActions />
<!-- 👉 Shortcuts -->
<ShortcutBar v-if="superUser" />
<!-- 👉 Theme -->
<NavbarThemeSwitcher />
<!-- 👉 Notification -->
<UserNofification />
<!-- 👉 UserProfile -->
@@ -91,7 +95,7 @@ onMounted(() => {
<VerticalNavSectionTitle
v-if="discoveryMenus.length > 0"
:item="{
heading: '发现',
heading: t('menu.discovery'),
}"
/>
<VerticalNavLink v-for="item in discoveryMenus" :item="item" />
@@ -99,7 +103,7 @@ onMounted(() => {
<VerticalNavSectionTitle
v-if="subscribeMenus.length > 0"
:item="{
heading: '订阅',
heading: t('menu.subscribe'),
}"
/>
<VerticalNavLink v-for="item in subscribeMenus" :item="item" />
@@ -107,7 +111,7 @@ onMounted(() => {
<VerticalNavSectionTitle
v-if="organizeMenus.length > 0"
:item="{
heading: '整理',
heading: t('menu.organize'),
}"
/>
<VerticalNavLink v-for="item in organizeMenus" :item="item" />
@@ -115,7 +119,7 @@ onMounted(() => {
<VerticalNavSectionTitle
v-if="systemMenus.length > 0"
:item="{
heading: '系统',
heading: t('menu.system'),
}"
/>
<VerticalNavLink v-for="item in systemMenus" :item="item" />

View File

@@ -1,16 +1,20 @@
<script setup lang="ts">
import { SystemNavMenus } from '@/router/menu'
import { getNavMenus } from '@/router/i18n-menu'
import { useDisplay } from 'vuetify'
import { NavMenu } from '@/@layouts/types'
const display = useDisplay()
const appMode = inject('pwaMode') && display.mdAndDown.value
const route = useRoute()
// 获取导航菜单
const navMenus = computed(() => getNavMenus())
// 根据当前路径获取匹配的菜单路径
function getMenuPathFromRoute(path: string): string {
const matchedMenu = SystemNavMenus.find(menu => menu.footer === true && path.startsWith(menu.to))
return matchedMenu ? matchedMenu.to : '/apps'
const matchedMenu = navMenus.value.find((menu: NavMenu) => menu.footer === true && path.startsWith(menu.to as string))
return matchedMenu ? (matchedMenu.to as string) : '/apps'
}
// 当前选中的菜单,初始值基于当前路由
@@ -18,7 +22,7 @@ const currentMenu = ref<string>(getMenuPathFromRoute(route.path))
// 过滤出底部菜单项
const footerMenus = computed(() => {
return SystemNavMenus.filter(menu => menu.footer === true)
return navMenus.value.filter((menu: NavMenu) => menu.footer === true)
})
// 监听路由变化来更新currentMenu

View File

@@ -2,10 +2,10 @@
const props = defineProps({
modelValue: {
type: String,
default: '* * * * *',
default: '',
},
items: {
type: Array as PropType<{ title: string; icon: string }[]>,
type: Array as PropType<{ title: string; icon: string; tab: string }[]>,
default: () => [],
},
})
@@ -76,8 +76,8 @@ onUnmounted(() => {
v-for="(item, index) in items"
:key="index"
class="header-tab"
:class="{ 'active': currentValue === item.title }"
@click="currentValue = item.title"
:class="{ 'active': currentValue === item.tab }"
@click="currentValue = item.tab"
>
<VIcon v-if="item.icon" :icon="item.icon" size="small" class="header-tab-icon" />
<span>{{ item.title }}</span>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import type { ThemeSwitcherTheme } from '@layouts/types'
import { useI18n } from 'vue-i18n'
import LocaleSwitcher from '@/@core/components/LocaleSwitcher.vue'
const { t } = useI18n()
const themes: ThemeSwitcherTheme[] = [
{
name: 'auto',
title: t('theme.auto'),
icon: 'mdi-laptop',
},
{
name: 'light',
title: t('theme.light'),
icon: 'mdi-weather-sunny',
},
{
name: 'dark',
title: t('theme.dark'),
icon: 'mdi-weather-night',
},
{
name: 'purple',
title: t('theme.purple'),
icon: 'mdi-brightness-4',
},
{
name: 'transparent',
title: t('theme.transparent'),
icon: 'mdi-gradient-horizontal',
},
]
</script>
<template>
<div class="d-flex align-center">
<!-- 语言切换 -->
<LocaleSwitcher class="me-2" />
<!-- 主题切换 -->
<ThemeSwitcher :themes="themes" />
</div>
</template>

View File

@@ -7,11 +7,14 @@ import api from '@/api'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
import UserAuthDialog from '@/components/dialog/UserAuthDialog.vue'
import { useAuthStore, useUserStore } from '@/stores'
import { useI18n } from 'vue-i18n'
// 认证 Store
const authStore = useAuthStore()
// 用户 Store
const userStore = useUserStore()
// 国际化
const { t } = useI18n()
// 确认框
const createConfirm = useConfirm()
@@ -153,7 +156,7 @@ const userLevel = computed(() => userStore.level)
<template #prepend>
<VIcon icon="mdi-logout" />
</template>
退出登录
{{ t('app.logout') }}
</VBtn>
</div>
</VList>
@@ -161,7 +164,7 @@ const userLevel = computed(() => userStore.level)
<!-- !SECTION -->
</VAvatar>
<!-- 重启进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" text="正在重启 ..." />
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="t('app.restarting')" />
<!-- 用户认证对话框 -->
<UserAuthDialog v-if="siteAuthDialog" v-model="siteAuthDialog" @done="siteAuthDone" @close="siteAuthDialog = false" />
<!-- 重启确认对话框 -->
@@ -173,14 +176,18 @@ const userLevel = computed(() => userStore.level)
<VIcon size="x-large" icon="mdi-alert" />
</VAvatar>
<div class="ms-3">
<p class="font-weight-bold text-xl text-high-emphasis">确认重启系统吗</p>
<p>重启后您将被注销并需要重新登录</p>
<p class="font-weight-bold text-xl text-high-emphasis">{{ t('app.confirmRestart') }}</p>
<p>{{ t('app.restartTip') }}</p>
</div>
</div>
</VCardItem>
<VCardActions class="mx-auto">
<VBtn variant="tonal" color="secondary" class="px-5" @click="restartDialog = false">取消</VBtn>
<VBtn variant="elevated" color="error" @click="restart" prepend-icon="mdi-restart" class="px-5"> 确定 </VBtn>
<VBtn variant="tonal" color="secondary" class="px-5" @click="restartDialog = false">{{
t('common.cancel')
}}</VBtn>
<VBtn variant="elevated" color="error" @click="restart" prepend-icon="mdi-restart" class="px-5">{{
t('common.confirm')
}}</VBtn>
</VCardActions>
<VDialogCloseBtn @click="restartDialog = false" />
</VCard>

149
src/locales/en-US.ts Normal file
View File

@@ -0,0 +1,149 @@
export default {
common: {
confirm: 'Confirm',
cancel: 'Cancel',
save: 'Save',
delete: 'Delete',
edit: 'Edit',
add: 'Add',
search: 'Search',
loading: 'Loading',
success: 'Success',
error: 'Error',
},
theme: {
light: 'Light',
dark: 'Dark',
auto: 'Auto',
transparent: 'Transparent',
purple: 'Purple',
custom: 'Custom',
},
app: {
moviepilot: 'MoviePilot',
recommend: 'Recommend',
subscribeMovie: 'Movie Subscription',
subscribeTv: 'TV Subscription',
settings: 'Settings',
language: 'Language',
selectLanguage: 'Select Language',
logout: 'Logout',
restarting: 'Restarting...',
confirmRestart: 'Confirm Restart?',
restartTip: 'After restarting, you will be logged out and need to log in again.',
},
login: {
wallpapers: 'wallpapers',
username: 'Username',
password: 'Password',
otpCode: 'OTP Code',
stayLoggedIn: 'Stay Logged In',
login: 'Login',
networkError: 'Login failed, please check your network connection!',
authFailure: 'Login failed, please check your username, password or OTP code!',
permissionDenied: 'Login failed, you do not have permission to access!',
serverError: 'Login failed, server error!',
loginFailed: 'Login Failed',
checkCredentials: 'Please check your username, password or OTP code!',
},
menu: {
start: 'Start',
discovery: 'Discovery',
subscribe: 'Subscribe',
organize: 'Organize',
system: 'System',
},
navItems: {
dashboard: 'Dashboard',
mediaInfo: 'Media Library',
recommend: 'Recommend',
site: 'Sites',
search: 'Search',
searchResult: 'Search Results',
download: 'Download',
movieSubscribe: 'Movie Subscription',
tvSubscribe: 'TV Subscription',
history: 'History',
transfer: 'Transfer',
rename: 'Rename',
statistic: 'Statistics',
setting: 'Settings',
plugin: 'Plugins',
user: 'Users',
about: 'About',
explore: 'Explore',
movie: 'Movies',
tv: 'TV Shows',
workflow: 'Workflow',
calendar: 'Calendar',
downloadManager: 'Download Manager',
mediaOrganize: 'Media Organizer',
fileManager: 'File Manager',
pluginManager: 'Plugins',
siteManager: 'Site Manager',
userManager: 'User Manager',
settings: 'Settings',
},
settingTabs: {
system: {
title: 'System',
description: 'Basic settings, downloaders (Qbittorrent, Transmission), media servers (Emby, Jellyfin, Plex)',
},
directory: {
title: 'Storage & Directories',
description: 'Download directories, media library directories, organization, metadata scraping',
},
site: {
title: 'Sites',
description: 'Site synchronization, site data refresh, site reset',
},
rule: {
title: 'Rules',
description: 'Custom rules, priority rule groups, download rules',
},
search: {
title: 'Search & Download',
description: 'Search data sources (TheMovieDb, Douban, Bangumi), download task labels, search sites',
},
subscribe: {
title: 'Subscription',
description: 'Subscription sites, subscription modes, subscription rules, upgrade rules',
},
scheduler: {
title: 'Services',
description: 'Scheduled tasks',
},
notification: {
title: 'Notifications',
description: 'Notification channels (WeChat, Telegram, Slack, SynologyChat, VoceChat, WebPush), message scope',
},
words: {
title: 'Word Lists',
description: 'Custom recognition words, custom groups, custom placeholders, file organization filter words',
},
about: {
title: 'About',
description: 'Software version',
},
},
subscribeTabs: {
movie: {
mysub: 'My Subscriptions',
popular: 'Popular',
},
tv: {
mysub: 'My Subscriptions',
popular: 'Popular',
share: 'Shared',
},
},
pluginTabs: {
installed: 'My Plugins',
market: 'Plugin Market',
},
discoverTabs: {
themoviedb: 'TheMovieDb',
douban: 'Douban',
bangumi: 'Bangumi',
},
}

25
src/locales/types.ts Normal file
View File

@@ -0,0 +1,25 @@
import zhCN from './zh-CN'
export type MessageSchema = typeof zhCN
export type LocaleKey = keyof typeof zhCN
export interface LocaleInfo {
name: string
title: string
flag?: string
}
export const SUPPORTED_LOCALES: Record<string, LocaleInfo> = {
'zh-CN': {
name: 'zh-CN',
title: '简体中文',
flag: '🇨🇳',
},
'en-US': {
name: 'en-US',
title: 'English',
flag: '🇺🇸',
},
}
export type SupportedLocale = keyof typeof SUPPORTED_LOCALES

149
src/locales/zh-CN.ts Normal file
View File

@@ -0,0 +1,149 @@
export default {
common: {
confirm: '确认',
cancel: '取消',
save: '保存',
delete: '删除',
edit: '编辑',
add: '添加',
search: '搜索',
loading: '加载中',
success: '成功',
error: '错误',
},
theme: {
light: '浅色',
dark: '深色',
auto: '跟随系统',
transparent: '透明',
purple: '幻紫',
custom: '自定义主题',
},
app: {
moviepilot: 'MoviePilot',
recommend: '推荐',
subscribeMovie: '电影订阅',
subscribeTv: '电视剧订阅',
settings: '设置',
language: '语言设置',
selectLanguage: '选择语言',
logout: '退出登录',
restarting: '正在重启...',
confirmRestart: '确认重启系统吗?',
restartTip: '重启后,您将被注销并需要重新登录。',
},
login: {
wallpapers: '壁纸',
username: '用户名',
password: '密码',
otpCode: '双重验证码',
stayLoggedIn: '保持登录',
login: '登录',
networkError: '登录失败,请检查网络连接!',
authFailure: '登录失败,请检查用户名、密码或双重验证是否正确!',
permissionDenied: '登录失败,您没有权限访问!',
serverError: '登录失败,服务器错误!',
loginFailed: '登录失败',
checkCredentials: '请检查用户名、密码或双重验证码是否正确!',
},
menu: {
start: '开始',
discovery: '发现',
subscribe: '订阅',
organize: '整理',
system: '系统',
},
navItems: {
dashboard: '仪表盘',
mediaInfo: '媒体库',
recommend: '推荐',
site: '站点',
search: '搜索',
searchResult: '搜索结果',
download: '下载',
movieSubscribe: '电影订阅',
tvSubscribe: '电视剧订阅',
history: '历史记录',
transfer: '整理',
rename: '重命名',
statistic: '统计',
setting: '设置',
plugin: '插件',
user: '用户',
about: '关于',
explore: '探索',
movie: '电影',
tv: '电视剧',
workflow: '工作流',
calendar: '日历',
downloadManager: '下载管理',
mediaOrganize: '媒体整理',
fileManager: '文件管理',
pluginManager: '插件',
siteManager: '站点管理',
userManager: '用户管理',
settings: '设定',
},
settingTabs: {
system: {
title: '系统',
description: '基础设置、下载器Qbittorrent、Transmission、媒体服务器Emby、Jellyfin、Plex',
},
directory: {
title: '存储 & 目录',
description: '下载目录、媒体库目录、整理、刮削',
},
site: {
title: '站点',
description: '站点同步、站点数据刷新、站点重置',
},
rule: {
title: '规则',
description: '自定义规则、优先级规则组、下载规则',
},
search: {
title: '搜索 & 下载',
description: '搜索数据源TheMovieDb、豆瓣、Bangumi、下载任务标签、搜索站点',
},
subscribe: {
title: '订阅',
description: '订阅站点、订阅模式、订阅规则、洗版规则',
},
scheduler: {
title: '服务',
description: '定时作业',
},
notification: {
title: '通知',
description: '通知渠道微信、Telegram、Slack、SynologyChat、VoceChat、WebPush、消息发送范围',
},
words: {
title: '词表',
description: '自定义识别词、自定义制作组/字幕组、自定义占位符、文件整理屏蔽词',
},
about: {
title: '关于',
description: '软件版本',
},
},
subscribeTabs: {
movie: {
mysub: '我的订阅',
popular: '热门订阅',
},
tv: {
mysub: '我的订阅',
popular: '热门订阅',
share: '订阅分享',
},
},
pluginTabs: {
installed: '我的插件',
market: '插件市场',
},
discoverTabs: {
themoviedb: 'TheMovieDb',
douban: '豆瓣',
bangumi: 'Bangumi',
},
}

View File

@@ -9,6 +9,7 @@ import { createApp } from 'vue'
import vuetify from '@/plugins/vuetify'
import router from '@/router'
import pinia from '@/stores/index'
import i18n from '@/plugins/i18n'
// 3. 全局组件
import App from '@/App.vue'
@@ -117,5 +118,6 @@ initializeApp().then(() => {
cancellationText: '取消',
},
})
.use(i18n)
.mount('#app')
})

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" />

69
src/plugins/i18n.ts Normal file
View File

@@ -0,0 +1,69 @@
import { createI18n } from 'vue-i18n'
import { nextTick } from 'vue'
import { SUPPORTED_LOCALES, SupportedLocale } from '@/locales/types'
// 导入语言文件
import zhCN from '@/locales/zh-CN'
import enUS from '@/locales/en-US'
// 创建 i18n 实例
const i18n = createI18n({
legacy: false, // 使用组合式API
locale: getBrowserLocale() || 'zh-CN', // 默认语言
fallbackLocale: 'zh-CN', // 回退语言
messages: {
'zh-CN': zhCN,
'en-US': enUS,
},
silentTranslationWarn: true,
silentFallbackWarn: true,
})
/**
* 获取浏览器语言设置
*/
export function getBrowserLocale(): SupportedLocale | null {
// 从本地存储获取
const storedLocale = localStorage.getItem('MP_LOCALE')
if (storedLocale && Object.keys(SUPPORTED_LOCALES).includes(storedLocale)) {
return storedLocale as SupportedLocale
}
// 从浏览器获取
const navigatorLocale = navigator.languages?.[0] || navigator.language || 'zh-CN'
// 检查是否为支持的语言
const locale = Object.keys(SUPPORTED_LOCALES).find(locale => {
return navigatorLocale.includes(locale.split('-')[0])
})
return (locale as SupportedLocale) || null
}
/**
* 设置i18n语言环境
*/
export async function setI18nLanguage(locale: SupportedLocale) {
// 加载语言文件(如果使用动态导入)
// await loadLocaleMessages(i18n, locale)
// 更新 i18n 实例语言
i18n.global.locale.value = locale as any as any
// 保存到本地存储
localStorage.setItem('MP_LOCALE', locale)
// 更新 HTML 标签 lang 属性
document.querySelector('html')?.setAttribute('lang', locale)
return nextTick()
}
/**
* 获取当前语言
*/
export function getCurrentLocale(): SupportedLocale {
return i18n.global.locale.value as SupportedLocale
}
export default i18n

274
src/router/i18n-menu.ts Normal file
View File

@@ -0,0 +1,274 @@
import { useI18n } from 'vue-i18n'
// 构建路由菜单,每次调用时使用当前的语言环境
export function getNavMenus() {
const { t } = useI18n()
return [
{
title: t('navItems.dashboard'),
icon: 'mdi-home-outline',
to: '/dashboard',
header: t('menu.start'),
admin: false,
footer: true,
},
{
title: t('navItems.searchResult'),
icon: 'mdi-magnify',
to: '/resource',
header: t('menu.start'),
admin: false,
},
{
title: t('navItems.recommend'),
icon: 'mdi-star-outline',
to: '/recommend',
header: t('menu.discovery'),
admin: false,
footer: true,
},
{
title: t('navItems.explore'),
icon: 'mdi-apple-safari',
to: '/discover',
header: t('menu.discovery'),
admin: false,
footer: true,
},
{
title: t('navItems.movie'),
full_title: t('navItems.movieSubscribe'),
icon: 'mdi-movie-open-outline',
to: '/subscribe/movie',
header: t('menu.subscribe'),
admin: false,
footer: false,
},
{
title: t('navItems.tv'),
full_title: t('navItems.tvSubscribe'),
icon: 'mdi-television',
to: '/subscribe/tv',
header: t('menu.subscribe'),
admin: false,
footer: false,
},
{
title: t('navItems.workflow'),
full_title: t('navItems.workflow'),
icon: 'mdi-state-machine',
to: '/workflow',
header: t('menu.subscribe'),
admin: true,
footer: false,
},
{
title: t('navItems.calendar'),
full_title: t('navItems.calendar'),
icon: 'mdi-calendar',
to: '/calendar',
header: t('menu.subscribe'),
admin: false,
},
{
title: t('navItems.downloadManager'),
icon: 'mdi-download-outline',
to: '/downloading',
header: t('menu.organize'),
admin: false,
},
{
title: t('navItems.mediaOrganize'),
icon: 'mdi-folder-play-outline',
to: '/history',
header: t('menu.organize'),
admin: true,
},
{
title: t('navItems.fileManager'),
icon: 'mdi-folder-multiple-outline',
to: '/filemanager',
header: t('menu.organize'),
admin: true,
},
{
title: t('navItems.pluginManager'),
icon: 'mdi-puzzle-outline',
to: '/plugins',
header: t('menu.system'),
admin: true,
},
{
title: t('navItems.siteManager'),
icon: 'mdi-web',
to: '/site',
header: t('menu.system'),
admin: true,
},
{
title: t('navItems.userManager'),
icon: 'mdi-account-group-outline',
to: '/user',
header: t('menu.system'),
admin: true,
},
{
title: t('navItems.settings'),
icon: 'mdi-cog-outline',
to: '/setting',
header: t('menu.system'),
admin: true,
},
]
}
// 获取设置标签页
export function getSettingTabs() {
const { t } = useI18n()
return [
{
title: t('settingTabs.system.title'),
icon: 'mdi-server-network',
tab: 'system',
description: t('settingTabs.system.description'),
},
{
title: t('settingTabs.directory.title'),
icon: 'mdi-folder',
tab: 'directory',
description: t('settingTabs.directory.description'),
},
{
title: t('settingTabs.site.title'),
icon: 'mdi-web',
tab: 'site',
description: t('settingTabs.site.description'),
},
{
title: t('settingTabs.rule.title'),
icon: 'mdi-filter',
tab: 'rule',
description: t('settingTabs.rule.description'),
},
{
title: t('settingTabs.search.title'),
icon: 'mdi-magnify',
tab: 'search',
description: t('settingTabs.search.description'),
},
{
title: t('settingTabs.subscribe.title'),
icon: 'mdi-rss',
tab: 'subscribe',
description: t('settingTabs.subscribe.description'),
},
{
title: t('settingTabs.scheduler.title'),
icon: 'mdi-list-box',
tab: 'scheduler',
description: t('settingTabs.scheduler.description'),
},
{
title: t('settingTabs.notification.title'),
icon: 'mdi-bell',
tab: 'notification',
description: t('settingTabs.notification.description'),
},
{
title: t('settingTabs.words.title'),
icon: 'mdi-file-word-box',
tab: 'words',
description: t('settingTabs.words.description'),
},
{
title: t('settingTabs.about.title'),
icon: 'mdi-information',
tab: 'about',
description: t('settingTabs.about.description'),
},
]
}
// 获取电影订阅标签页
export function getSubscribeMovieTabs() {
const { t } = useI18n()
return [
{
title: t('subscribeTabs.movie.mysub'),
tab: 'mysub',
icon: 'mdi-bell-check',
},
{
title: t('subscribeTabs.movie.popular'),
tab: 'popular',
icon: 'mdi-fire',
},
]
}
// 获取电视剧订阅标签页
export function getSubscribeTvTabs() {
const { t } = useI18n()
return [
{
title: t('subscribeTabs.tv.mysub'),
tab: 'mysub',
icon: 'mdi-bell-check',
},
{
title: t('subscribeTabs.tv.popular'),
tab: 'popular',
icon: 'mdi-fire',
},
{
title: t('subscribeTabs.tv.share'),
tab: 'share',
icon: 'mdi-share-variant',
},
]
}
// 获取插件标签页
export function getPluginTabs() {
const { t } = useI18n()
return [
{
title: t('pluginTabs.installed'),
tab: 'installed',
icon: 'mdi-puzzle',
},
{
title: t('pluginTabs.market'),
tab: 'market',
icon: 'mdi-shopping',
},
]
}
// 获取发现标签页
export function getDiscoverTabs() {
const { t } = useI18n()
return [
{
name: t('discoverTabs.themoviedb'),
tab: 'themoviedb',
icon: 'themoviedb',
},
{
name: t('discoverTabs.douban'),
tab: 'douban',
icon: 'douban',
},
{
name: t('discoverTabs.bangumi'),
tab: 'bangumi',
icon: 'bangumi',
},
]
}

View File

@@ -1,249 +0,0 @@
// 导般菜单
export const SystemNavMenus = [
{
title: '仪表板',
icon: 'mdi-home-outline',
to: '/dashboard',
header: '开始',
admin: false,
footer: true,
},
{
title: '搜索结果',
icon: 'mdi-magnify',
to: '/resource',
header: '开始',
admin: false,
},
{
title: '推荐',
icon: 'mdi-star-outline',
to: '/recommend',
header: '发现',
admin: false,
footer: true,
},
{
title: '探索',
icon: 'mdi-apple-safari',
to: '/discover',
header: '发现',
admin: false,
footer: true,
},
{
title: '电影',
full_title: '电影订阅',
icon: 'mdi-movie-open-outline',
to: '/subscribe/movie',
header: '订阅',
admin: false,
footer: false,
},
{
title: '电视剧',
full_title: '电视剧订阅',
icon: 'mdi-television',
to: '/subscribe/tv',
header: '订阅',
admin: false,
footer: false,
},
{
title: '工作流',
full_title: '工作流',
icon: 'mdi-state-machine',
to: '/workflow',
header: '订阅',
admin: true,
footer: false,
},
{
title: '日历',
full_title: '订阅日历',
icon: 'mdi-calendar',
to: '/calendar',
header: '订阅',
admin: false,
},
{
title: '下载管理',
icon: 'mdi-download-outline',
to: '/downloading',
header: '整理',
admin: false,
},
{
title: '媒体整理',
icon: 'mdi-folder-play-outline',
to: '/history',
header: '整理',
admin: true,
},
{
title: '文件管理',
icon: 'mdi-folder-multiple-outline',
to: '/filemanager',
header: '整理',
admin: true,
},
{
title: '插件',
icon: 'mdi-puzzle-outline',
to: '/plugins',
header: '系统',
admin: true,
},
{
title: '站点管理',
icon: 'mdi-web',
to: '/site',
header: '系统',
admin: true,
},
{
title: '用户管理',
icon: 'mdi-account-group-outline',
to: '/user',
header: '系统',
admin: true,
},
{
title: '设定',
icon: 'mdi-cog-outline',
to: '/setting',
header: '系统',
admin: true,
},
]
// 设定标签页
export const SettingTabs = [
{
title: '系统',
icon: 'mdi-server-network',
tab: 'system',
description: '基础设置、下载器Qbittorrent、Transmission、媒体服务器Emby、Jellyfin、Plex',
},
{
title: '存储 & 目录',
icon: 'mdi-folder',
tab: 'directory',
description: '下载目录、媒体库目录、整理、刮削',
},
{
title: '站点',
icon: 'mdi-web',
tab: 'site',
description: '站点同步、站点数据刷新、站点重置',
},
{
title: '规则',
icon: 'mdi-filter',
tab: 'rule',
description: '自定义规则、优先级规则组、下载规则',
},
{
title: '搜索 & 下载',
icon: 'mdi-magnify',
tab: 'search',
description: '搜索数据源TheMovieDb、豆瓣、Bangumi、下载任务标签、搜索站点',
},
{
title: '订阅',
icon: 'mdi-rss',
tab: 'subscribe',
description: '订阅站点、订阅模式、订阅规则、洗版规则',
},
{
title: '服务',
icon: 'mdi-list-box',
tab: 'scheduler',
description: '定时作业',
},
{
title: '通知',
icon: 'mdi-bell',
tab: 'notification',
description: '通知渠道微信、Telegram、Slack、SynologyChat、VoceChat、WebPush、消息发送范围',
},
{
title: '词表',
icon: 'mdi-file-word-box',
tab: 'words',
description: '自定义识别词、自定义制作组/字幕组、自定义占位符、文件整理屏蔽词',
},
{
title: '关于',
icon: 'mdi-information',
tab: 'about',
description: '软件版本',
},
]
// 电影订阅标签页
export const SubscribeMovieTabs = [
{
title: '我的订阅',
tab: 'mysub',
icon: 'mdi-bell-check',
},
{
title: '热门订阅',
tab: 'popular',
icon: 'mdi-fire',
},
]
// 电视剧订阅标签页
export const SubscribeTvTabs = [
{
title: '我的订阅',
tab: 'mysub',
icon: 'mdi-bell-check',
},
{
title: '热门订阅',
tab: 'popular',
icon: 'mdi-fire',
},
{
title: '订阅分享',
tab: 'share',
icon: 'mdi-share-variant',
},
]
// 插件标签页
export const PluginTabs = [
{
title: '我的插件',
tab: 'installed',
icon: 'mdi-puzzle',
},
{
title: '插件市场',
tab: 'market',
icon: 'mdi-shopping',
},
]
// 发现标签页
export const DiscoverTabs = [
{
name: 'TheMovieDb',
tab: 'themoviedb',
icon: 'themoviedb',
},
{
name: '豆瓣',
tab: 'douban',
icon: 'douban',
},
{
name: 'Bangumi',
tab: 'bangumi',
icon: 'bangumi',
},
]

View File

@@ -9,7 +9,7 @@ import PluginCard from '@/components/cards/PluginCard.vue'
import noImage from '@images/logos/plugin.png'
import { useDisplay } from 'vuetify'
import { isNullOrEmptyObject } from '@/@core/utils'
import { PluginTabs } from '@/router/menu'
import { getPluginTabs } from '@/router/i18n-menu'
import PluginMarketSettingDialog from '@/components/dialog/PluginMarketSettingDialog.vue'
import { useDynamicButton } from '@/composables/useDynamicButton'
@@ -22,7 +22,10 @@ const display = useDisplay()
const appMode = inject('pwaMode') && display.mdAndDown.value
// 当前标签
const activeTab = ref('我的插件')
const activeTab = ref('installed')
// 获取插件标签页
const pluginTabs = computed(() => getPluginTabs())
// 插件ID参数
const pluginId = ref(route.query.id)
@@ -326,7 +329,7 @@ async function fetchUninstalledPlugins() {
loading.value = false
isRefreshed.value = true
// 更新插件市场列表
// 排除已安装且有更新的,上面的问题在于本地存在未安装的旧版本插件且云端有更新时不会在插件市场展示
// 排除已安装且有更新的,上面的问题在于"本地存在未安装的旧版本插件且云端有更新时"不会在插件市场展示
marketList.value = uninstalledList.value.filter(item => !(item.has_update && item.installed))
// 初始化过滤选项
marketList.value.forEach(initOptions)
@@ -467,10 +470,10 @@ useDynamicButton({
<template>
<div>
<VHeaderTab :items="PluginTabs" v-model="activeTab">
<VHeaderTab :items="pluginTabs" v-model="activeTab">
<template #append>
<VMenu
v-if="activeTab === '我的插件'"
v-if="activeTab === 'installed'"
v-model="filterInstalledPluginDialog"
width="20rem"
:close-on-content-click="false"
@@ -513,7 +516,7 @@ useDynamicButton({
</VCard>
</VMenu>
<VMenu
v-if="activeTab === '插件市场'"
v-if="activeTab === 'market'"
v-model="filterMarketPluginDialog"
width="25rem"
:close-on-content-click="false"
@@ -586,7 +589,7 @@ useDynamicButton({
</VCard>
</VMenu>
<VBtn
v-if="activeTab === '插件市场'"
v-if="activeTab === 'market'"
icon="mdi-store-cog"
variant="text"
color="gray"
@@ -599,7 +602,7 @@ useDynamicButton({
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
<!-- 我的插件 -->
<VWindowItem value="我的插件">
<VWindowItem value="installed">
<transition name="fade-slide" appear>
<div>
<VPageContentTitle v-if="installedFilter" :title="`筛选:${installedFilter}`" />
@@ -638,7 +641,7 @@ useDynamicButton({
</transition>
</VWindowItem>
<!-- 插件市场 -->
<VWindowItem value="插件市场">
<VWindowItem value="market">
<transition name="fade-slide" appear>
<div>
<LoadingBanner v-if="!isAppMarketLoaded" class="mt-12" />

View File

@@ -6,6 +6,8 @@ import Components from 'unplugin-vue-components/vite'
import { defineConfig } from 'vite'
import vuetify from 'vite-plugin-vuetify'
import { VitePWA } from 'vite-plugin-pwa'
import VueI18n from '@intlify/unplugin-vue-i18n/vite'
import { resolve } from 'node:path'
// https://vitejs.dev/config/
export default defineConfig({
@@ -23,9 +25,12 @@ export default defineConfig({
dts: true,
}),
AutoImport({
imports: ['vue', 'vue-router', '@vueuse/core', '@vueuse/math', 'pinia'],
imports: ['vue', 'vue-router', '@vueuse/core', '@vueuse/math', 'pinia', 'vue-i18n'],
vueTemplate: true,
}),
VueI18n({
include: [resolve(__dirname, 'src/locales/**')],
}),
VitePWA({
injectRegister: 'script',
registerType: 'autoUpdate',