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

@@ -4,7 +4,7 @@ import { getNavMenus } from '@/router/i18n-menu'
import { usePluginSidebarNavStore, useUserStore } from '@/stores'
import { useI18n } from 'vue-i18n'
import { filterPluginSidebarNavEntries } from '@/utils/pluginSidebarNav'
import { filterMenusByPermission } from '@/utils/permission'
import { buildUserPermissionContext, filterMenusByPermission } from '@/utils/permission'
// 国际化
const { t } = useI18n()
@@ -13,10 +13,7 @@ const userStore = useUserStore()
const pluginSidebarNavStore = usePluginSidebarNavStore()
// 获取用户权限信息
const userPermissions = computed(() => ({
is_superuser: userStore.superUser,
...userStore.permissions,
}))
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
// 应用分组以header分组
const appGroups = ref<Record<string, NavMenu[]>>({})

View File

@@ -5,13 +5,14 @@ import 'gridstack/dist/gridstack.min.css'
import api from '@/api'
import { isNullOrEmptyObject } from '@/@core/utils'
import type { DashboardItem } from '@/api/types'
import { useUserStore } from '@/stores'
import DashboardElement from '@/components/misc/DashboardElement.vue'
import { useDynamicButton, type DynamicButtonMenuItem } from '@/composables/useDynamicButton'
import { useI18n } from 'vue-i18n'
import { usePWA } from '@/composables/usePWA'
import { getItemColor, initializeItemColors } from '@/utils/colorUtils'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
const ContentToggleSettingsDialog = defineAsyncComponent(
() => import('@/components/dialog/ContentToggleSettingsDialog.vue'),
@@ -22,13 +23,14 @@ const { t } = useI18n()
// PWA模式检测
const { appMode } = usePWA()
const userStore = useUserStore()
const canAdmin = computed(() =>
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
)
// 路由
const route = useRoute()
// 从用户 Store 中获取superuser信息
const superUser = useUserStore().superUser
const DASHBOARD_GRID_COLUMNS = 12
const DASHBOARD_GRID_CELL_HEIGHT = 16
const DASHBOARD_GRID_FALLBACK_ROWS = 4
@@ -461,6 +463,7 @@ const dashboardDynamicButtonMenuItems = computed<DynamicButtonMenuItem[] | undef
title: isLayoutEditing.value ? t('dashboard.exitEditMode') : t('dashboard.editLayout'),
icon: isLayoutEditing.value ? 'mdi-check' : 'mdi-view-dashboard-edit',
color: 'primary',
permission: 'admin',
action: toggleDashboardLayoutEditing,
},
]
@@ -470,6 +473,7 @@ const dashboardDynamicButtonMenuItems = computed<DynamicButtonMenuItem[] | undef
title: t('dashboard.resetLayout'),
icon: 'mdi-restore',
color: 'warning',
permission: 'admin',
action: resetDashboardGridLayout,
})
}
@@ -478,6 +482,7 @@ const dashboardDynamicButtonMenuItems = computed<DynamicButtonMenuItem[] | undef
title: t('dashboard.settings'),
icon: 'mdi-tune',
color: 'info',
permission: 'admin',
action: openDashboardSettings,
})
@@ -487,6 +492,7 @@ const dashboardDynamicButtonMenuItems = computed<DynamicButtonMenuItem[] | undef
useDynamicButton({
icon: 'mdi-view-dashboard-edit',
menuItems: dashboardDynamicButtonMenuItems,
permission: 'admin',
show: computed(() => appMode.value && route.path === '/dashboard'),
})
@@ -583,9 +589,6 @@ function buildPluginDashboardId(plugin_id: string, key: string) {
// 调用API获取所有插件的仪表板元信息
async function getPluginDashboardMeta() {
// 只有超级用户才能获取
if (!superUser) return
try {
pluginDashboardMeta.value = (await api.get('/plugin/dashboard/meta')) ?? []
if (!isNullOrEmptyObject(pluginDashboardMeta.value)) {
@@ -628,7 +631,7 @@ function schedulePluginDashboardRefresh(item: DashboardItem) {
}
function refreshEnabledPluginDashboards() {
if (!superUser || isNullOrEmptyObject(pluginDashboardMeta.value)) return
if (isNullOrEmptyObject(pluginDashboardMeta.value)) return
pluginDashboardMeta.value.forEach((pluginDashboard: { id: string; key: string }) => {
const pluginDashboardId = buildPluginDashboardId(pluginDashboard.id, pluginDashboard.key)
@@ -1048,7 +1051,7 @@ onBeforeUnmount(() => {
</div>
<Teleport to="body" v-if="!appMode && route.path === '/dashboard'">
<div class="compact-fab-stack">
<div v-if="canAdmin" class="compact-fab-stack">
<VFab
icon="mdi-tune"
color="info"

View File

@@ -179,6 +179,7 @@ registerHeaderTab({
variant: 'text',
color: 'grey',
class: 'settings-icon-button',
permission: 'discovery',
action: openOrderConfigDialog,
},
],

View File

@@ -12,7 +12,7 @@ import { SUPPORTED_LOCALES, SupportedLocale } from '@/types/i18n'
import { getCurrentLocale, setI18nLanguage } from '@/plugins/i18n'
import { useTheme } from 'vuetify'
import { getNavMenus } from '@/router/i18n-menu'
import { filterMenusByPermission } from '@/utils/permission'
import { buildUserPermissionContext, filterMenusByPermission } from '@/utils/permission'
import type { ApiResponse } from '@/api/types'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { loadRemoteComponentFromModule, type RemoteModule } from '@/utils/federationLoader'
@@ -539,10 +539,7 @@ async function handleLoginSuccess(response: any) {
wizard: response.wizard,
}
const userPermissions = {
is_superuser: userPayload.superUser,
...userPayload.permissions,
}
const userPermissions = buildUserPermissionContext(userPayload.superUser, userPayload.permissions)
const filteredMenus = filterMenusByPermission(navMenus.value, userPermissions)
if (filteredMenus.length === 0) {

View File

@@ -9,6 +9,8 @@ import { usePWA } from '@/composables/usePWA'
import { getItemColor, initializeItemColors } from '@/utils/colorUtils'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { getRecommendTabs } from '@/router/i18n-menu'
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
const ContentToggleSettingsDialog = defineAsyncComponent(() => import('@/components/dialog/ContentToggleSettingsDialog.vue'))
@@ -16,9 +18,13 @@ const { appMode } = usePWA()
// 国际化
const { t } = useI18n()
const userStore = useUserStore()
// 路由
const route = useRoute()
const canDiscovery = computed(() =>
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'discovery'),
)
// 当前选择的分类
const currentCategory = ref(t('recommend.all'))
@@ -235,6 +241,7 @@ registerHeaderTab({
useDynamicButton({
icon: 'mdi-tune',
onClick: openRecommendSettings,
permission: 'discovery',
show: computed(() => appMode.value),
})
@@ -298,7 +305,7 @@ onActivated(async () => {
<!-- 快速滚动到顶部按钮 -->
<Teleport to="body" v-if="route.path === '/recommend'">
<div v-if="!appMode" class="compact-fab-stack">
<div v-if="!appMode && canDiscovery" class="compact-fab-stack">
<VFab
icon="mdi-tune"
color="primary"

View File

@@ -17,11 +17,17 @@ import { useDynamicButton } from '@/composables/useDynamicButton'
import { usePWA } from '@/composables/usePWA'
import { useToast } from 'vue-toastification'
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
// 国际化
const { t } = useI18n()
const { appMode } = usePWA()
const userStore = useUserStore()
const canSearch = computed(() =>
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'search'),
)
// 提示框
const toast = useToast()
@@ -257,6 +263,7 @@ function toggleViewType() {
useDynamicButton({
icon: viewToggleIcon,
onClick: toggleViewType,
permission: 'search',
show: computed(() => appMode.value && isRefreshed.value),
})
@@ -1579,7 +1586,7 @@ onUnmounted(() => {
<LoadingBanner v-else-if="!isRefreshed && !isSearchLoading" />
<Teleport to="body" v-if="route.path === '/resource'">
<div v-if="isRefreshed && !appMode" class="compact-fab-stack">
<div v-if="isRefreshed && !appMode && canSearch" class="compact-fab-stack">
<VFab
:icon="viewToggleIcon"
color="primary"

View File

@@ -7,6 +7,7 @@ import { useDynamicButton } from '@/composables/useDynamicButton'
import { usePWA } from '@/composables/usePWA'
import { useUserStore } from '@/stores'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { getSubscribeMovieTabs, getSubscribeTvTabs } from '@/router/i18n-menu'
@@ -193,9 +194,12 @@ function selectSubscribeSort(value: SubscribeSortBy) {
const filterActivator = computed(() => '[data-menu-activator="filter-btn"]')
const searchActivator = computed(() => '[data-menu-activator="share-filter-btn"]')
const showDefaultRuleAction = computed(() => activeTab.value === 'mysub')
const showSubscribeHistoryAction = computed(() => showDefaultRuleAction.value && userStore.superUser)
const showShareStatisticsAction = computed(() => activeTab.value === 'share')
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
const canAdmin = computed(() => hasPermission(userPermissions.value, 'admin'))
const canSubscribe = computed(() => hasPermission(userPermissions.value, 'subscribe'))
const showDefaultRuleAction = computed(() => activeTab.value === 'mysub' && canAdmin.value)
const showSubscribeHistoryAction = computed(() => showDefaultRuleAction.value && canAdmin.value)
const showShareStatisticsAction = computed(() => activeTab.value === 'share' && canSubscribe.value)
function openDefaultRuleDialog() {
openSharedDialog(
@@ -255,6 +259,7 @@ const subscribeDynamicMenuItems = computed(() => {
titleKey: string
titleParams?: Record<string, unknown>
icon: string
permission: 'admin'
action: () => void
}> = []
@@ -263,6 +268,7 @@ const subscribeDynamicMenuItems = computed(() => {
titleKey: 'dialog.subscribeHistory.title',
titleParams: { type: subType },
icon: 'mdi-history',
permission: 'admin',
action: openSubscribeHistoryDialog,
})
}
@@ -270,6 +276,7 @@ const subscribeDynamicMenuItems = computed(() => {
items.push({
titleKey: 'dialog.subscribeEdit.titleDefault',
icon: 'mdi-clipboard-edit-outline',
permission: 'admin',
action: openDefaultRuleDialog,
})
@@ -305,6 +312,7 @@ useDynamicButton({
icon: subscribeDynamicIcon,
onClick: handleSubscribeDynamicAction,
menuItems: subscribeDynamicMenuItems,
permission: 'subscribe',
show: computed(() => appMode.value && (showDefaultRuleAction.value || showShareStatisticsAction.value)),
})
@@ -322,6 +330,7 @@ registerHeaderTab({
color: filterButtonColor,
class: 'settings-icon-button',
dataAttr: 'filter-btn',
permission: 'subscribe',
action: () => {
filterSubscribeDialog.value = true
},
@@ -332,6 +341,7 @@ registerHeaderTab({
variant: 'text',
color: computed(() => (subscribeSortMode.value ? 'warning' : 'gray')),
class: 'settings-icon-button',
permission: 'subscribe',
action: toggleSubscribeSortMode,
show: computed(() => activeTab.value === 'mysub'),
},
@@ -340,6 +350,7 @@ registerHeaderTab({
variant: 'text',
color: 'gray',
class: 'settings-icon-button',
permission: 'subscribe',
action: () => {
// 触发批量管理模式
const event = new CustomEvent('toggle-batch-mode')
@@ -353,6 +364,7 @@ registerHeaderTab({
color: computed(() => (shareKeywordInput.value ? 'primary' : 'gray')),
class: 'settings-icon-button',
dataAttr: 'share-filter-btn',
permission: 'subscribe',
action: () => {
searchShareDialog.value = true
},

View File

@@ -7,12 +7,18 @@ import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { usePWA } from '@/composables/usePWA'
import { getWorkflowTabs } from '@/router/i18n-menu'
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
// 国际化
const { t } = useI18n()
const route = useRoute()
const { appMode } = usePWA()
const userStore = useUserStore()
const canManage = computed(() =>
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'manage'),
)
const activeTab = ref((route.query.tab as string) || 'list')
const workflowListViewRef = ref<InstanceType<typeof WorkflowListView> | null>(null)
@@ -61,6 +67,7 @@ onUnmounted(() => {
useDynamicButton({
icon: 'mdi-plus',
onClick: openAddWorkflowDialog,
permission: 'manage',
show: computed(() => appMode.value && activeTab.value === 'list'),
})
@@ -78,6 +85,7 @@ registerHeaderTab({
color: computed(() => (shareKeywordInput.value ? 'primary' : 'gray')),
class: 'settings-icon-button',
dataAttr: 'share-filter-btn',
permission: 'manage',
show: computed(() => activeTab.value === 'share'),
action: () => {
searchShareDialog.value = true
@@ -138,7 +146,7 @@ onMounted(() => {
</VMenu>
</Teleport>
<Teleport to="body" v-if="!appMode && route.path === '/workflow' && activeTab === 'list'">
<Teleport to="body" v-if="!appMode && route.path === '/workflow' && activeTab === 'list' && canManage">
<div class="compact-fab-stack">
<VFab
icon="mdi-plus"