mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-07-21 04:22:09 +08:00
fix: enforce permission-aware navigation
This commit is contained in:
@@ -16,7 +16,14 @@ import { NavMenu } from '@/@layouts/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { filterMenusByPermission } from '@/utils/permission'
|
||||
import {
|
||||
buildUserPermissionContext,
|
||||
filterItemsByPermission,
|
||||
filterMenusByPermission,
|
||||
hasItemPermission,
|
||||
hasPermission,
|
||||
type UserPermissionKey,
|
||||
} from '@/utils/permission'
|
||||
import { onUnreadMessage } from '@/utils/badge'
|
||||
import { usePullDownGesture } from '@/composables/usePullDownGesture'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
@@ -41,17 +48,12 @@ const themeLayout = ref(readThemeCustomizerSettings().layout)
|
||||
const userStore = useUserStore()
|
||||
const pluginSidebarNavStore = usePluginSidebarNavStore()
|
||||
|
||||
// 响应式的超级用户状态
|
||||
const superUser = computed(() => userStore.superUser)
|
||||
|
||||
// ShortcutBar 引用
|
||||
const shortcutBarRef = ref<InstanceType<typeof ShortcutBar> | null>(null)
|
||||
|
||||
// 获取用户权限信息
|
||||
const userPermissions = computed(() => ({
|
||||
is_superuser: userStore.superUser,
|
||||
...userStore.permissions,
|
||||
}))
|
||||
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||
const canAdmin = computed(() => hasPermission(userPermissions.value, 'admin'))
|
||||
|
||||
// 开始菜单项
|
||||
const startMenus = ref<NavMenu[]>([])
|
||||
@@ -112,6 +114,7 @@ interface DynamicHeaderTabButton {
|
||||
size?: string
|
||||
class?: string
|
||||
action?: () => void
|
||||
permission?: UserPermissionKey
|
||||
show?: boolean | ComputedRef<boolean>
|
||||
loading?: boolean | ComputedRef<boolean>
|
||||
dataAttr?: string
|
||||
@@ -196,10 +199,19 @@ const hasDynamicHeaderTab = computed(() => {
|
||||
// 水平布局下动态标签页会并入顶部导航三级菜单,不再额外显示标签页栏。
|
||||
const showDynamicHeaderTab = computed(() => hasDynamicHeaderTab.value && !showHorizontalThemeNav.value)
|
||||
|
||||
const visibleHorizontalHeaderButtons = computed(() => {
|
||||
if (!showHorizontalThemeNav.value || !hasDynamicHeaderTab.value) return []
|
||||
const visibleDynamicHeaderButtons = computed(() => {
|
||||
if (!hasDynamicHeaderTab.value) return []
|
||||
|
||||
return (dynamicHeaderTab.value?.appendButtons ?? []).filter(button => resolveMaybeRefValue(button.show, true) !== false)
|
||||
const visibleButtons = (dynamicHeaderTab.value?.appendButtons ?? []).filter(
|
||||
button => resolveMaybeRefValue(button.show, true) !== false,
|
||||
)
|
||||
return filterItemsByPermission(visibleButtons, userPermissions.value)
|
||||
})
|
||||
|
||||
const visibleHorizontalHeaderButtons = computed(() => {
|
||||
if (!showHorizontalThemeNav.value) return []
|
||||
|
||||
return visibleDynamicHeaderButtons.value
|
||||
})
|
||||
|
||||
// 在组件销毁时清理
|
||||
@@ -227,7 +239,7 @@ const canUsePullGesture = () => {
|
||||
// 检查是否在dashboard页面
|
||||
const isDashboard = route.path === '/dashboard' || route.path === '/'
|
||||
// 检查是否是管理员
|
||||
const isAdmin = superUser.value
|
||||
const isAdmin = canAdmin.value
|
||||
// 检查插件快速访问面板是否已显示
|
||||
const quickAccessOpen = showPluginQuickAccess.value
|
||||
// 检查是否离线
|
||||
@@ -323,6 +335,12 @@ function resolveHeaderButtonLoading(button: DynamicHeaderTabButton) {
|
||||
return resolveMaybeRefValue(button.loading, false)
|
||||
}
|
||||
|
||||
function handleHeaderButtonClick(button: DynamicHeaderTabButton) {
|
||||
if (!hasItemPermission(button, userPermissions.value)) return
|
||||
|
||||
button.action?.()
|
||||
}
|
||||
|
||||
function getHorizontalTabIcon(tab: DynamicHeaderTabItem) {
|
||||
const icon = tab.icon?.trim()
|
||||
|
||||
@@ -366,7 +384,7 @@ function applyPendingHorizontalTab() {
|
||||
|
||||
// 处理未读消息事件
|
||||
function handleUnreadMessage(count: number) {
|
||||
if (superUser.value && count > 0) {
|
||||
if (canAdmin.value && count > 0) {
|
||||
// 延迟一点时间确保组件已渲染
|
||||
setTimeout(() => {
|
||||
if (shortcutBarRef.value && typeof shortcutBarRef.value.openMessageDialog === 'function') {
|
||||
@@ -480,7 +498,7 @@ onMounted(async () => {
|
||||
class="theme-navbar-row d-flex h-14 align-center mx-1"
|
||||
:class="{ 'theme-navbar-row--horizontal': showHorizontalThemeNav }"
|
||||
>
|
||||
<RouterLink v-if="showHorizontalThemeNav" to="/dashboard" class="theme-horizontal-logo">
|
||||
<RouterLink v-if="showHorizontalThemeNav" :to="canAdmin ? '/dashboard' : '/apps'" class="theme-horizontal-logo">
|
||||
<span class="theme-horizontal-logo__mark" v-html="logo" />
|
||||
<span class="theme-horizontal-logo__text">MOVIEPILOT</span>
|
||||
</RouterLink>
|
||||
@@ -503,7 +521,7 @@ onMounted(async () => {
|
||||
<!-- 👉 Horizontal Search Icon -->
|
||||
<SearchBar v-if="showHorizontalThemeNav" icon-only />
|
||||
<!-- 👉 Shortcuts -->
|
||||
<ShortcutBar v-if="superUser" ref="shortcutBarRef" />
|
||||
<ShortcutBar v-if="canAdmin" ref="shortcutBarRef" />
|
||||
<!-- 👉 Notification -->
|
||||
<UserNofification />
|
||||
<!-- 👉 UserProfile -->
|
||||
@@ -597,7 +615,7 @@ onMounted(async () => {
|
||||
:class="button.class || 'settings-icon-button'"
|
||||
:loading="resolveHeaderButtonLoading(button)"
|
||||
:data-menu-activator="button.dataAttr"
|
||||
@click="button.action"
|
||||
@click="handleHeaderButtonClick(button)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -650,17 +668,16 @@ onMounted(async () => {
|
||||
@update:model-value="handleTabChange"
|
||||
>
|
||||
<template #append>
|
||||
<template v-for="button in dynamicHeaderTab!.appendButtons" :key="button.icon">
|
||||
<template v-for="button in visibleDynamicHeaderButtons" :key="button.icon">
|
||||
<VBtn
|
||||
v-if="typeof button.show === 'boolean' ? button.show !== false : (button.show as any)?.value !== false"
|
||||
:icon="button.icon"
|
||||
:variant="button.variant || 'text'"
|
||||
:color="typeof button.color === 'string' ? button.color : (button.color as any)?.value || 'gray'"
|
||||
:color="resolveHeaderButtonColor(button)"
|
||||
:size="button.size || 'default'"
|
||||
:class="button.class || 'settings-icon-button'"
|
||||
:loading="typeof button.loading === 'boolean' ? button.loading : (button.loading as any)?.value || false"
|
||||
:loading="resolveHeaderButtonLoading(button)"
|
||||
:data-menu-activator="button.dataAttr"
|
||||
@click="button.action"
|
||||
@click="handleHeaderButtonClick(button)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useDisplay } from 'vuetify'
|
||||
import { NavMenu } from '@/@layouts/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { filterMenusByPermission } from '@/utils/permission'
|
||||
import { buildUserPermissionContext, filterItemsByPermission, filterMenusByPermission, hasItemPermission } from '@/utils/permission'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import type { DynamicButtonMenuItem } from '@/composables/useDynamicButton'
|
||||
|
||||
@@ -42,10 +42,7 @@ const userPermissions = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
is_superuser: userStore.superUser,
|
||||
...userStore.permissions,
|
||||
}
|
||||
return buildUserPermissionContext(userStore.superUser, userStore.permissions)
|
||||
})
|
||||
|
||||
// 获取导航菜单
|
||||
@@ -119,6 +116,7 @@ watch(
|
||||
interface DynamicButton {
|
||||
icon: string
|
||||
action: () => void
|
||||
permission?: DynamicButtonMenuItem['permission']
|
||||
show: boolean
|
||||
routePath?: string // 添加路径属性,用于标识哪个路由注册的
|
||||
menuItems?: DynamicButtonMenuItem[]
|
||||
@@ -166,12 +164,17 @@ const showDynamicButton = computed(() => {
|
||||
return (
|
||||
dynamicButton.value &&
|
||||
dynamicButton.value.show &&
|
||||
hasItemPermission(dynamicButton.value, userPermissions.value) &&
|
||||
// 确保只在注册的路由路径下显示按钮
|
||||
(!dynamicButton.value.routePath || dynamicButton.value.routePath === route.path)
|
||||
)
|
||||
})
|
||||
|
||||
const hasDynamicButtonMenu = computed(() => Boolean(dynamicButton.value?.menuItems?.length))
|
||||
const visibleDynamicButtonMenuItems = computed(() => {
|
||||
return filterItemsByPermission(dynamicButton.value?.menuItems ?? [], userPermissions.value)
|
||||
})
|
||||
|
||||
const hasDynamicButtonMenu = computed(() => visibleDynamicButtonMenuItems.value.length > 0)
|
||||
|
||||
const legacyDynamicMenuTitleKeyMap: Record<string, string> = {
|
||||
'components.subscribeHistory.title': 'dialog.subscribeHistory.title',
|
||||
@@ -194,6 +197,18 @@ function resolveDynamicMenuItemTitle(item: DynamicButtonMenuItem) {
|
||||
|
||||
return looksLikeI18nKey ? t(normalizedTitleKey, item.titleParams as any) : item.title
|
||||
}
|
||||
|
||||
function handleDynamicButtonClick() {
|
||||
if (!dynamicButton.value || !hasItemPermission(dynamicButton.value, userPermissions.value)) return
|
||||
|
||||
dynamicButton.value.action()
|
||||
}
|
||||
|
||||
function handleDynamicMenuItemClick(item: DynamicButtonMenuItem) {
|
||||
if (!hasItemPermission(item, userPermissions.value)) return
|
||||
|
||||
item.action()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -257,7 +272,7 @@ function resolveDynamicMenuItemTitle(item: DynamicButtonMenuItem) {
|
||||
icon
|
||||
variant="text"
|
||||
:ripple="false"
|
||||
@click="!hasDynamicButtonMenu && dynamicButton?.action()"
|
||||
@click="!hasDynamicButtonMenu && handleDynamicButtonClick()"
|
||||
rounded="pill"
|
||||
class="footer-nav-btn"
|
||||
>
|
||||
@@ -270,10 +285,10 @@ function resolveDynamicMenuItemTitle(item: DynamicButtonMenuItem) {
|
||||
<VMenu v-if="hasDynamicButtonMenu" activator="parent" location="top end" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(item, index) in dynamicButton?.menuItems"
|
||||
v-for="(item, index) in visibleDynamicButtonMenuItems"
|
||||
:key="item.titleKey || item.title || index"
|
||||
:base-color="item.color"
|
||||
@click="item.action()"
|
||||
@click="handleDynamicMenuItemClick(item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon v-if="item.icon" :icon="item.icon" />
|
||||
|
||||
@@ -3,9 +3,13 @@ import type { Component } from 'vue'
|
||||
import { getQueryValue } from '@/@core/utils'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, filterItemsByPermission, hasItemPermission, type PermissionProtectedItem } from '@/utils/permission'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||
|
||||
// 快捷工具只在弹窗打开时使用,按需加载避免默认布局首屏带上所有 system 视图。
|
||||
const NameTestView = defineAsyncComponent(() => import('@/views/system/NameTestView.vue'))
|
||||
@@ -19,7 +23,7 @@ const ShortcutLogDialog = defineAsyncComponent(() => import('@/components/dialog
|
||||
const ShortcutMessageDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutMessageDialog.vue'))
|
||||
const ShortcutToolDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutToolDialog.vue'))
|
||||
|
||||
type ShortcutItem = {
|
||||
type ShortcutItem = PermissionProtectedItem & {
|
||||
bodyClass?: string
|
||||
cardClass?: string
|
||||
component?: Component
|
||||
@@ -119,10 +123,14 @@ const shortcuts: ShortcutItem[] = [
|
||||
dialog: 'message',
|
||||
customDialog: ShortcutMessageDialog,
|
||||
},
|
||||
]
|
||||
].map(item => ({ ...item, permission: 'admin' }))
|
||||
|
||||
const visibleShortcuts = computed(() => filterItemsByPermission(shortcuts, userPermissions.value))
|
||||
|
||||
/** 打开快捷工具对应的共享弹窗。 */
|
||||
function openShortcutDialog(item: (typeof shortcuts)[number]) {
|
||||
if (!hasItemPermission(item, userPermissions.value)) return
|
||||
|
||||
appsMenu.value = false
|
||||
|
||||
if (item.customDialog) {
|
||||
@@ -150,7 +158,7 @@ function openShortcutDialog(item: (typeof shortcuts)[number]) {
|
||||
|
||||
/** 供外部调用的打开消息弹窗方法。 */
|
||||
function openMessageDialogFromExternal() {
|
||||
const messageShortcut = shortcuts.find(item => item.dialog === 'message')
|
||||
const messageShortcut = visibleShortcuts.value.find(item => item.dialog === 'message')
|
||||
if (messageShortcut) openShortcutDialog(messageShortcut)
|
||||
}
|
||||
|
||||
@@ -162,7 +170,7 @@ defineExpose({
|
||||
onMounted(() => {
|
||||
const shortcut = getQueryValue('shortcut')
|
||||
if (shortcut) {
|
||||
const found = shortcuts.find(item => item.dialog === shortcut)
|
||||
const found = visibleShortcuts.value.find(item => item.dialog === shortcut)
|
||||
if (found) {
|
||||
openShortcutDialog(found)
|
||||
}
|
||||
@@ -202,7 +210,7 @@ onMounted(() => {
|
||||
<div class="pa-3">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<!-- 循环渲染快捷方式 -->
|
||||
<div v-for="(item, index) in shortcuts" :key="index">
|
||||
<div v-for="(item, index) in visibleShortcuts" :key="index">
|
||||
<VCard
|
||||
flat
|
||||
class="pa-2 d-flex align-center cursor-pointer transition-transform duration-300 hover:-translate-y-1 border h-full"
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
THEME_CUSTOMIZER_CHANGE_EVENT,
|
||||
type ThemeCustomizerSettings,
|
||||
} from '@/composables/useThemeCustomizer'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
|
||||
const AboutDialog = defineAsyncComponent(() => import('@/components/dialog/AboutDialog.vue'))
|
||||
const CustomCssDialog = defineAsyncComponent(() => import('@/components/dialog/CustomCssDialog.vue'))
|
||||
@@ -104,7 +105,7 @@ function closeRestartProgress() {
|
||||
// 检测服务状态
|
||||
async function checkServiceStatus(): Promise<boolean> {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/env', { timeout: 3000 })
|
||||
const result: { [key: string]: any } = await api.get('system/ping', { timeout: 3000 })
|
||||
return result?.success === true
|
||||
} catch (error) {
|
||||
return false
|
||||
@@ -163,6 +164,8 @@ async function pollServiceStatus() {
|
||||
|
||||
// 执行重启操作
|
||||
async function restart() {
|
||||
if (!canAdmin.value) return
|
||||
|
||||
// 设置重启状态
|
||||
isRestarting.value = true
|
||||
|
||||
@@ -194,6 +197,8 @@ async function restart() {
|
||||
|
||||
// 显示重启确认对话框
|
||||
async function showRestartDialog() {
|
||||
if (!canAdmin.value) return
|
||||
|
||||
const isConfirmed = await createConfirm({
|
||||
type: 'warn',
|
||||
title: t('app.confirmRestart'),
|
||||
@@ -207,6 +212,8 @@ async function showRestartDialog() {
|
||||
|
||||
/** 显示站点认证共享弹窗。 */
|
||||
function showSiteAuthDialog() {
|
||||
if (!canAdmin.value || userLevel.value >= 2) return
|
||||
|
||||
siteAuthDialogController?.close()
|
||||
siteAuthDialogController = openSharedDialog(
|
||||
UserAuthDialog,
|
||||
@@ -220,6 +227,8 @@ function showSiteAuthDialog() {
|
||||
|
||||
/** 显示关于共享弹窗。 */
|
||||
function showAboutDialog() {
|
||||
if (!canAdmin.value) return
|
||||
|
||||
openSharedDialog(AboutDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
|
||||
}
|
||||
|
||||
@@ -232,6 +241,8 @@ function siteAuthDone() {
|
||||
|
||||
// 从用户 Store中获取信息
|
||||
const superUser = computed(() => userStore.superUser)
|
||||
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||
const canAdmin = computed(() => hasPermission(userPermissions.value, 'admin'))
|
||||
const userName = computed(() => userStore.userName)
|
||||
const avatar = computed(() => userStore.avatar || avatar1)
|
||||
const userLevel = computed(() => userStore.level)
|
||||
@@ -384,6 +395,8 @@ function handleThemeCustomizerSettingsChange(event: Event) {
|
||||
|
||||
// 获取自定义 CSS
|
||||
async function getCustomCSS() {
|
||||
if (!canAdmin.value) return
|
||||
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/UserCustomCSS')
|
||||
if (result && result.success && result.data?.value) {
|
||||
@@ -401,6 +414,8 @@ async function getCustomCSS() {
|
||||
|
||||
/** 打开自定义 CSS 共享弹窗。 */
|
||||
function showCustomCssDialog() {
|
||||
if (!canAdmin.value) return
|
||||
|
||||
customCssDialogController?.close()
|
||||
customCssDialogController = openSharedDialog(
|
||||
CustomCssDialog,
|
||||
@@ -433,6 +448,8 @@ function showThemeCustomizerDrawer() {
|
||||
|
||||
/** 保存自定义 CSS。 */
|
||||
async function saveCustomCSS(css: string) {
|
||||
if (!canAdmin.value) return
|
||||
|
||||
customCSS.value = css
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('system/setting/UserCustomCSS', css, {
|
||||
@@ -512,7 +529,7 @@ const getThemeIcon = computed(() => {
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
getCustomCSS()
|
||||
if (canAdmin.value) getCustomCSS()
|
||||
window.addEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerSettingsChange)
|
||||
|
||||
// 初始化透明度设置
|
||||
@@ -577,7 +594,7 @@ onUnmounted(() => {
|
||||
</VListItem>
|
||||
|
||||
<VListItem
|
||||
v-if="superUser"
|
||||
v-if="canAdmin"
|
||||
link
|
||||
@click="isAdvancedMode ? router.push('/setting') : router.push('/setup-wizard')"
|
||||
class="mb-1 rounded-lg"
|
||||
@@ -590,7 +607,7 @@ onUnmounted(() => {
|
||||
</VListItem>
|
||||
|
||||
<!-- 👉 Site Auth -->
|
||||
<VListItem v-if="userLevel < 2 && superUser" link @click="showSiteAuthDialog" class="mb-1 rounded-lg" hover>
|
||||
<VListItem v-if="userLevel < 2 && canAdmin" link @click="showSiteAuthDialog" class="mb-1 rounded-lg" hover>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-lock-check-outline" />
|
||||
</template>
|
||||
@@ -674,7 +691,7 @@ onUnmounted(() => {
|
||||
</VListItem>
|
||||
</template>
|
||||
|
||||
<VListItem @click="showCustomCssDialog">
|
||||
<VListItem v-if="canAdmin" @click="showCustomCssDialog">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-palette" />
|
||||
</template>
|
||||
@@ -729,7 +746,7 @@ onUnmounted(() => {
|
||||
</VListItem>
|
||||
|
||||
<!-- 👉 About -->
|
||||
<VListItem @click="showAboutDialog" class="mb-1 rounded-lg" hover>
|
||||
<VListItem v-if="canAdmin" @click="showAboutDialog" class="mb-1 rounded-lg" hover>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-information-outline" />
|
||||
</template>
|
||||
@@ -737,10 +754,10 @@ onUnmounted(() => {
|
||||
</VListItem>
|
||||
|
||||
<!-- Divider -->
|
||||
<VDivider v-if="superUser" class="my-3" />
|
||||
<VDivider v-if="canAdmin" class="my-3" />
|
||||
|
||||
<!-- 👉 restart -->
|
||||
<VListItem v-if="superUser" @click="showRestartDialog" class="mb-1 rounded-lg" hover>
|
||||
<VListItem v-if="canAdmin" @click="showRestartDialog" class="mb-1 rounded-lg" hover>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-restart" />
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user