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

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

View File

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

View File

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

View File

@@ -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初始化

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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