mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-07-01 12:31:39 +08:00
fix: enforce permission-aware navigation
This commit is contained in:
@@ -2,7 +2,6 @@
|
||||
import { useTheme } from 'vuetify'
|
||||
import api from '@/api'
|
||||
import { hexToRgb } from '@layouts/utils'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
@@ -10,10 +9,6 @@ const { t } = useI18n()
|
||||
|
||||
const vuetifyTheme = useTheme()
|
||||
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
const superUser = userStore.superUser
|
||||
|
||||
const options = controlledComputed(
|
||||
() => vuetifyTheme.name.value,
|
||||
() => {
|
||||
@@ -147,7 +142,7 @@ onActivated(() => {
|
||||
<p>{{ t('dashboard.weeklyOverviewDescription', { count: totalCount }) }} 😎</p>
|
||||
</div>
|
||||
<div>
|
||||
<VBtn v-if="superUser" block to="/history"> {{ t('common.viewDetails') }} </VBtn>
|
||||
<VBtn block to="/history"> {{ t('common.viewDetails') }} </VBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { openMediaServerItem, openDoubanApp } from '@/utils/appDeepLink'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
@@ -39,10 +39,9 @@ const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
|
||||
const canSearch = computed(() =>
|
||||
hasPermission({ is_superuser: userStore.superUser, ...userStore.permissions }, 'search'),
|
||||
)
|
||||
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||
const canSearch = computed(() => hasPermission(userPermissions.value, 'search'))
|
||||
const canSubscribe = computed(() => hasPermission(userPermissions.value, 'subscribe'))
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
@@ -130,7 +129,7 @@ async function querySites() {
|
||||
// 查询用户选中的站点
|
||||
async function querySelectedSites() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/IndexerSites')
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/IndexerSites')
|
||||
|
||||
selectedSites.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
@@ -543,12 +542,11 @@ async function handlePlay() {
|
||||
}
|
||||
|
||||
async function queryDefaultSubscribeConfig() {
|
||||
// 非管理员不显示
|
||||
if (!userStore.superUser) return false
|
||||
if (!canSubscribe.value) return false
|
||||
try {
|
||||
let subscribe_config_url = ''
|
||||
if (mediaProps.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
|
||||
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
|
||||
if (mediaProps.type === '电影') subscribe_config_url = 'system/setting/public/DefaultMovieSubscribeConfig'
|
||||
else subscribe_config_url = 'system/setting/public/DefaultTvSubscribeConfig'
|
||||
|
||||
const result: { [key: string]: any } = await api.get(subscribe_config_url)
|
||||
|
||||
@@ -697,7 +695,7 @@ onBeforeMount(() => {
|
||||
{{ t('media.actions.searchSubtitle') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="mediaDetail.type === '电影' || mediaDetail.douban_id || mediaDetail.bangumi_id"
|
||||
v-if="canSubscribe && (mediaDetail.type === '电影' || mediaDetail.douban_id || mediaDetail.bangumi_id)"
|
||||
class="ms-2 mb-2"
|
||||
:color="getSubscribeColor"
|
||||
variant="tonal"
|
||||
@@ -812,6 +810,7 @@ onBeforeMount(() => {
|
||||
{{ getExistText(season.season_number || 0) }}
|
||||
</VChip>
|
||||
<IconBtn
|
||||
v-if="canSubscribe"
|
||||
class="ms-1"
|
||||
:color="seasonsSubscribed[season.season_number || 0] ? 'error' : 'warning'"
|
||||
variant="text"
|
||||
|
||||
@@ -6,7 +6,7 @@ import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { getPluginTabs } from '@/router/i18n-menu'
|
||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
import { useDynamicButton, type DynamicButtonMenuItem } from '@/composables/useDynamicButton'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import PluginMixedSortCard from '@/components/cards/PluginMixedSortCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
@@ -14,11 +14,14 @@ import { usePWA } from '@/composables/usePWA'
|
||||
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||||
import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 市场卡片、拖拽排序和市场设置只在对应标签/操作中需要,延迟到真正使用时加载。
|
||||
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
|
||||
@@ -63,6 +66,7 @@ registerHeaderTab({
|
||||
),
|
||||
class: 'settings-icon-button',
|
||||
dataAttr: 'installed-filter-btn',
|
||||
permission: 'admin',
|
||||
action: () => {
|
||||
filterInstalledPluginDialog.value = true
|
||||
},
|
||||
@@ -73,6 +77,7 @@ registerHeaderTab({
|
||||
variant: 'text',
|
||||
color: computed(() => (sortMode.value ? 'warning' : 'gray')),
|
||||
class: 'settings-icon-button',
|
||||
permission: 'admin',
|
||||
action: () => {
|
||||
sortMode.value = !sortMode.value
|
||||
},
|
||||
@@ -84,6 +89,7 @@ registerHeaderTab({
|
||||
color: computed(() => (isFilterFormEmpty.value ? 'gray' : 'primary')),
|
||||
class: 'settings-icon-button',
|
||||
dataAttr: 'market-filter-btn',
|
||||
permission: 'admin',
|
||||
action: () => {
|
||||
filterMarketPluginDialog.value = true
|
||||
},
|
||||
@@ -95,6 +101,7 @@ registerHeaderTab({
|
||||
color: 'gray',
|
||||
class: 'settings-icon-button',
|
||||
loading: computed(() => isMarketRefreshing.value),
|
||||
permission: 'admin',
|
||||
action: () => {
|
||||
refreshMarket()
|
||||
},
|
||||
@@ -105,6 +112,7 @@ registerHeaderTab({
|
||||
variant: 'text',
|
||||
color: 'gray',
|
||||
class: 'settings-icon-button',
|
||||
permission: 'admin',
|
||||
action: () => {
|
||||
backToMain()
|
||||
},
|
||||
@@ -1058,17 +1066,23 @@ function openMarketSettingDialog() {
|
||||
}
|
||||
|
||||
const showSearchAction = computed(() => activeTab.value === 'installed' || activeTab.value === 'market')
|
||||
const showNewFolderAction = computed(() => activeTab.value === 'installed' && !currentFolder.value)
|
||||
const showMarketSettingAction = computed(() => activeTab.value === 'market')
|
||||
const canAdmin = computed(() =>
|
||||
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
|
||||
)
|
||||
const showNewFolderAction = computed(() => activeTab.value === 'installed' && !currentFolder.value && canAdmin.value)
|
||||
const showMarketSettingAction = computed(
|
||||
() => activeTab.value === 'market' && canAdmin.value,
|
||||
)
|
||||
|
||||
const pluginDynamicMenuItems = computed(() => {
|
||||
if (!appMode.value) return undefined
|
||||
if (!showSearchAction.value) return undefined
|
||||
|
||||
const items = [
|
||||
const items: DynamicButtonMenuItem[] = [
|
||||
{
|
||||
titleKey: 'plugin.searchPlugins',
|
||||
icon: 'mdi-magnify',
|
||||
permission: 'admin',
|
||||
action: openPluginSearchDialog,
|
||||
},
|
||||
]
|
||||
@@ -1077,6 +1091,7 @@ const pluginDynamicMenuItems = computed(() => {
|
||||
items.push({
|
||||
titleKey: 'plugin.newFolder',
|
||||
icon: 'mdi-folder-plus',
|
||||
permission: 'admin',
|
||||
action: showNewFolderDialog,
|
||||
})
|
||||
}
|
||||
@@ -1085,6 +1100,7 @@ const pluginDynamicMenuItems = computed(() => {
|
||||
items.push({
|
||||
titleKey: 'dialog.pluginMarketSetting.title',
|
||||
icon: 'mdi-store-cog',
|
||||
permission: 'admin',
|
||||
action: openMarketSettingDialog,
|
||||
})
|
||||
}
|
||||
@@ -1096,6 +1112,7 @@ useDynamicButton({
|
||||
icon: 'mdi-magnify',
|
||||
onClick: openPluginSearchDialog,
|
||||
menuItems: pluginDynamicMenuItems,
|
||||
permission: 'admin',
|
||||
show: computed(() => appMode.value && showSearchAction.value && isRefreshed.value),
|
||||
})
|
||||
|
||||
@@ -1831,7 +1848,7 @@ function onDragStartPlugin(evt: any) {
|
||||
|
||||
<!-- 插件搜索图标 -->
|
||||
<Teleport to="body" v-if="route.path === '/plugins'">
|
||||
<div v-if="isRefreshed && !appMode && showSearchAction" class="compact-fab-stack">
|
||||
<div v-if="isRefreshed && !appMode && showSearchAction && canAdmin" class="compact-fab-stack">
|
||||
<VFab
|
||||
v-if="showMarketSettingAction"
|
||||
icon="mdi-store-cog"
|
||||
|
||||
@@ -131,10 +131,10 @@ function determineBrowserInitialParams(downloadDirectories: TransferDirectoryCon
|
||||
async function loadDownloadDirectories() {
|
||||
try {
|
||||
// fetch available storages
|
||||
const storageResult: { [key: string]: any } = await api.get('system/setting/Storages')
|
||||
const storageResult: { [key: string]: any } = await api.get('system/setting/public/Storages')
|
||||
storages.value = storageResult.data?.value ?? []
|
||||
|
||||
const result: { [key: string]: any } = await api.get('system/setting/Directories')
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/Directories')
|
||||
if (result.success && result.data?.value) {
|
||||
const { storage, path, name } = determineBrowserInitialParams(result.data.value)
|
||||
// operItem初始化
|
||||
|
||||
@@ -12,11 +12,12 @@ import { useDisplay } from 'vuetify'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
import { useDynamicButton, type DynamicButtonMenuItem } from '@/composables/useDynamicButton'
|
||||
import { useAvailableHeight } from '@/composables/useAvailableHeight'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { useGlobalSettingsStore, useUserStore } from '@/stores'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
|
||||
const TransferHistoryDeleteDialog = defineAsyncComponent(
|
||||
() => import('@/components/dialog/TransferHistoryDeleteDialog.vue'),
|
||||
@@ -42,6 +43,10 @@ const $toast = useToast()
|
||||
|
||||
// 路由
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const canManage = computed(() =>
|
||||
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'manage'),
|
||||
)
|
||||
let syncingRouteQuery = false
|
||||
let fetchDataRequestSeed = 0
|
||||
|
||||
@@ -243,7 +248,7 @@ const storages = ref<StorageConf[]>([])
|
||||
// 查询存储
|
||||
async function loadStorages() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/Storages')
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/Storages')
|
||||
|
||||
storages.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
@@ -852,10 +857,11 @@ const historyDynamicIcon = computed(() => (selected.value.length > 0 ? 'mdi-chev
|
||||
const historyDynamicMenuItems = computed(() => {
|
||||
if (selected.value.length === 0) return undefined
|
||||
|
||||
const items: Array<{ titleKey: string; icon: string; action: () => void; color?: string }> = [
|
||||
const items: DynamicButtonMenuItem[] = [
|
||||
{
|
||||
titleKey: 'dialog.transferQueue.title',
|
||||
icon: 'mdi-timer-sand-paused',
|
||||
permission: 'manage',
|
||||
action: openTransferQueueDialog,
|
||||
},
|
||||
]
|
||||
@@ -865,6 +871,7 @@ const historyDynamicMenuItems = computed(() => {
|
||||
{
|
||||
titleKey: 'transferHistory.actions.batchAiRedo',
|
||||
icon: 'mdi-robot-outline',
|
||||
permission: 'manage',
|
||||
action: () => {
|
||||
triggerBatchAiRedo()
|
||||
},
|
||||
@@ -872,6 +879,7 @@ const historyDynamicMenuItems = computed(() => {
|
||||
{
|
||||
titleKey: 'transferHistory.actions.batchRedo',
|
||||
icon: 'mdi-redo-variant',
|
||||
permission: 'manage',
|
||||
action: () => {
|
||||
retransferBatch()
|
||||
},
|
||||
@@ -880,6 +888,7 @@ const historyDynamicMenuItems = computed(() => {
|
||||
titleKey: 'transferHistory.actions.batchDelete',
|
||||
icon: 'mdi-trash-can-outline',
|
||||
color: 'error',
|
||||
permission: 'manage',
|
||||
action: () => {
|
||||
removeHistoryBatch()
|
||||
},
|
||||
@@ -894,6 +903,7 @@ useDynamicButton({
|
||||
icon: historyDynamicIcon,
|
||||
onClick: openTransferQueueDialog,
|
||||
menuItems: historyDynamicMenuItems,
|
||||
permission: 'manage',
|
||||
show: computed(() => appMode.value),
|
||||
})
|
||||
|
||||
@@ -1173,7 +1183,7 @@ onUnmounted(() => {
|
||||
|
||||
<!-- 非 app 模式下的 FAB 按钮 -->
|
||||
<Teleport to="body" v-if="!appMode && route.path === '/history'">
|
||||
<div v-if="isRefreshed" class="compact-fab-stack compact-fab-stack--history">
|
||||
<div v-if="isRefreshed && canManage" class="compact-fab-stack compact-fab-stack--history">
|
||||
<VFab
|
||||
v-if="selected.length > 0 && !hasRunningAiRedo"
|
||||
icon="mdi-trash-can-outline"
|
||||
|
||||
@@ -128,7 +128,7 @@ function orderDirectoryCards() {
|
||||
// 查询存储
|
||||
async function loadStorages() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/Storages')
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/Storages')
|
||||
|
||||
storages.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
@@ -150,7 +150,7 @@ async function saveStorages() {
|
||||
// 查询目录
|
||||
async function loadDirectories() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/Directories')
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/Directories')
|
||||
directories.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
|
||||
@@ -92,7 +92,7 @@ async function queryFilterRuleGroups() {
|
||||
// 查询用户选中的站点
|
||||
async function querySelectedSites() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/IndexerSites')
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/IndexerSites')
|
||||
|
||||
selectedSites.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,12 +4,14 @@ import type { Site, SiteUserData } from '@/api/types'
|
||||
import SiteCard from '@/components/cards/SiteCard.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
import { useDynamicButton, type DynamicButtonMenuItem } from '@/composables/useDynamicButton'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -22,6 +24,10 @@ const route = useRoute()
|
||||
|
||||
// APP 模式检测
|
||||
const { appMode } = usePWA()
|
||||
const userStore = useUserStore()
|
||||
const canManage = computed(() =>
|
||||
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'manage'),
|
||||
)
|
||||
|
||||
// 拖拽排序和站点弹窗都不是站点列表首屏必需,打开对应功能时再加载。
|
||||
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
|
||||
@@ -333,28 +339,32 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const shouldShowFloatingActions = computed(() => route.path === '/site' && isRefreshed.value)
|
||||
const shouldShowFloatingActions = computed(() => route.path === '/site' && isRefreshed.value && canManage.value)
|
||||
|
||||
// App 模式下将站点操作收纳到 Footer 动态菜单中,和插件页保持一致。
|
||||
const siteDynamicMenuItems = computed(() => [
|
||||
const siteDynamicMenuItems = computed<DynamicButtonMenuItem[]>(() => [
|
||||
{
|
||||
titleKey: 'site.actions.add',
|
||||
icon: 'mdi-web-plus',
|
||||
permission: 'manage',
|
||||
action: openSiteAddDialog,
|
||||
},
|
||||
{
|
||||
titleKey: 'site.actions.import',
|
||||
icon: 'mdi-import',
|
||||
permission: 'manage',
|
||||
action: openSiteImportDialog,
|
||||
},
|
||||
{
|
||||
titleKey: 'site.actions.export',
|
||||
icon: 'mdi-export',
|
||||
permission: 'manage',
|
||||
action: exportSites,
|
||||
},
|
||||
{
|
||||
titleKey: 'site.statistics',
|
||||
icon: 'mdi-chart-line',
|
||||
permission: 'manage',
|
||||
action: openSiteStatisticsDialog,
|
||||
},
|
||||
])
|
||||
@@ -364,6 +374,7 @@ useDynamicButton({
|
||||
icon: 'mdi-web-plus',
|
||||
onClick: openSiteAddDialog,
|
||||
menuItems: siteDynamicMenuItems,
|
||||
permission: 'manage',
|
||||
show: computed(() => appMode.value && shouldShowFloatingActions.value),
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -174,7 +174,7 @@ async function saveTransferExcludeWords() {
|
||||
// 查询集数定位规则
|
||||
async function queryEpisodeFormatRules() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/EpisodeFormatRuleTable')
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/EpisodeFormatRuleTable')
|
||||
if (result && result.data && result.data.value) {
|
||||
episodeFormatRules.value = normalizeEpisodeFormatRules(result.data.value)
|
||||
} else {
|
||||
|
||||
@@ -8,6 +8,8 @@ import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
|
||||
const UserAddEditDialog = defineAsyncComponent(() => import('@/components/dialog/UserAddEditDialog.vue'))
|
||||
|
||||
@@ -16,6 +18,10 @@ const { t } = useI18n()
|
||||
|
||||
// 路由
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const canAdmin = computed(() =>
|
||||
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
|
||||
)
|
||||
|
||||
// PWA模式检测
|
||||
const { appMode } = usePWA()
|
||||
@@ -79,6 +85,7 @@ useDynamicButton({
|
||||
onClick: () => {
|
||||
openAddUserDialog()
|
||||
},
|
||||
permission: 'admin',
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -110,7 +117,7 @@ useDynamicButton({
|
||||
|
||||
<!-- 新增用户按钮 -->
|
||||
<Teleport to="body" v-if="route.path === '/user'">
|
||||
<div v-if="isRefreshed && !appMode" class="compact-fab-stack">
|
||||
<div v-if="isRefreshed && !appMode && canAdmin" class="compact-fab-stack">
|
||||
<VFab
|
||||
icon="mdi-account-plus"
|
||||
color="primary"
|
||||
|
||||
Reference in New Issue
Block a user