fix: enforce permission-aware navigation

This commit is contained in:
jxxghp
2026-06-09 21:45:51 +08:00
parent d0cac34d08
commit 4691d12faa
42 changed files with 483 additions and 239 deletions

View File

@@ -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>

View File

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

View File

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

View File

@@ -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>