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

@@ -1,5 +1,6 @@
import type { Component, Ref, VNode } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import type { UserPermissionKey } from '@/utils/permission'
import { ContentWidth, FooterType, NavbarType } from './enums'
export interface UserConfig {
@@ -119,6 +120,7 @@ export interface NavLink extends NavLinkProps, Partial<AclProperties> {
badgeContent?: string
badgeClass?: string
disable?: boolean
permission?: UserPermissionKey
}
export interface NavMenuTabItem {

View File

@@ -98,7 +98,7 @@ const startHeartbeat = () => {
heartbeatInterval = window.setInterval(async () => {
try {
if (isLogin.value) {
await api.get('dashboard/cpu')
await api.get('system/ping')
}
} catch (error) {
console.warn('Heartbeat request failed:', error)

View File

@@ -7,6 +7,8 @@ import { storageIconDict } from '@/api/constants'
import type { AxiosInstance } from 'axios'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { usePWA } from '@/composables/usePWA'
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
// LocalStorage keys
const SORT_KEY = 'fileBrowser.sort'
@@ -41,6 +43,10 @@ const props = defineProps({
const emit = defineEmits(['pathchanged'])
const route = useRoute()
const { appMode } = usePWA()
const userStore = useUserStore()
const canManage = computed(() =>
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'manage'),
)
const toolbarRef = ref<InstanceType<typeof FileToolbar> | null>(null)
const fileIcons = {
@@ -136,11 +142,12 @@ function openNewFolderDialog() {
toolbarRef.value?.openNewFolderDialog()
}
const showFloatingNewFolderAction = computed(() => route.path === '/filemanager')
const showFloatingNewFolderAction = computed(() => route.path === '/filemanager' && canManage.value)
useDynamicButton({
icon: 'mdi-folder-plus-outline',
onClick: openNewFolderDialog,
permission: 'manage',
show: computed(() => appMode.value && showFloatingNewFolderAction.value),
})

View File

@@ -10,7 +10,7 @@ import router from '@/router'
import { useUserStore, useGlobalSettingsStore } from '@/stores'
import { useI18n } from 'vue-i18n'
import { mediaTypeDict } from '@/api/constants'
import { hasPermission } from '@/utils/permission'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { openSharedDialog } from '@/composables/useSharedDialog'
import {
getCachedMediaExistsStatus,
@@ -45,6 +45,9 @@ const globalSettings = globalSettingsStore.globalSettings
// 用户 Store
const userStore = useUserStore()
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()
@@ -143,7 +146,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) {
console.log(error)
@@ -336,12 +339,11 @@ async function checkSubscribe(season: number | null) {
// 查询订阅弹窗规则
async function queryDefaultSubscribeConfig() {
// 非管理员不显示
if (!userStore.superUser) return false
if (!canSubscribe.value) return false
try {
let subscribe_config_url = ''
if (props.media?.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
if (props.media?.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)
if (result.data?.value) return result.data.value.show_edit_dialog
} catch (error) {
@@ -534,7 +536,7 @@ onBeforeUnmount(() => {
<div v-if="props.media?.collection_id" class="mb-3" @click.stop=""></div>
<div v-else class="flex align-center justify-between">
<IconBtn
v-if="hasPermission({ is_superuser: userStore.superUser, ...userStore.permissions }, 'search')"
v-if="canSearch"
icon="mdi-magnify"
color="white"
size="small"
@@ -542,6 +544,7 @@ onBeforeUnmount(() => {
/>
<VSpacer />
<IconBtn
v-if="canSubscribe"
icon="mdi-heart"
:color="isSubscribed ? 'error' : 'white'"
size="small"

View File

@@ -71,7 +71,7 @@ const buttonText = computed(() =>
// 加载目录设置
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

@@ -63,7 +63,7 @@ const buttonText = computed(() =>
// 加载目录设置
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

@@ -51,7 +51,7 @@ function toggleExpand() {
// 加载follow用户列表
async function queryFollowUsers() {
try {
const result: { [key: string]: any } = await api.get('system/setting/FollowSubscribers')
const result: { [key: string]: any } = await api.get('system/setting/public/FollowSubscribers')
followUsers.value = result.data?.value ?? []
} catch (error) {
console.log(error)

View File

@@ -108,7 +108,7 @@ function switchEditorMode(mode: EditorMode | undefined) {
/** 加载插件市场仓库配置。 */
async function queryMarketRepoSetting() {
try {
const result: { [key: string]: any } = await api.get('system/setting/PLUGIN_MARKET')
const result: { [key: string]: any } = await api.get('system/setting/public/PLUGIN_MARKET')
if (result && result.data && result.data.value) {
repoList.value = parseRepoInput(result.data.value).repos
syncTextFromList()

View File

@@ -175,7 +175,7 @@ let episodeGroupQueryTimer: ReturnType<typeof setTimeout> | undefined
// 查询存储
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) {
@@ -292,7 +292,7 @@ const directories = ref<TransferDirectoryConf[]>([])
// 查询目录
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)
@@ -848,7 +848,7 @@ async function requestManualTransfer<T = any>(
// 加载剧集格式规则配置状态,用于决定是否允许自动推荐。
async function loadEpisodeFormatRuleConfiguration() {
try {
const result: { [key: string]: any } = await api.get('system/setting/EpisodeFormatRuleTable')
const result: { [key: string]: any } = await api.get('system/setting/public/EpisodeFormatRuleTable')
episodeFormatRuleConfigured.value = Boolean(result.data?.value?.length)
} catch (error) {
console.log(error)

View File

@@ -7,7 +7,7 @@ import { useUserStore, useGlobalSettingsStore } from '@/stores'
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { hasPermission, filterMenusByPermission } from '@/utils/permission'
import { buildUserPermissionContext, hasPermission, filterMenusByPermission } from '@/utils/permission'
// 显示器宽度
const display = useDisplay()
@@ -30,41 +30,29 @@ const userStore = useUserStore()
const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings
// 超级用户
const superUser = userStore.superUser
// 当前用户名
const userName = userStore.userName
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
// 权限检查
const hasSearchPermission = computed(() => {
return hasPermission(
{
is_superuser: userStore.superUser,
...userStore.permissions,
},
'search',
)
return hasPermission(userPermissions.value, 'search')
})
const hasDiscoveryPermission = computed(() => {
return hasPermission(userPermissions.value, 'discovery')
})
const hasSubscribePermission = computed(() => {
return hasPermission(
{
is_superuser: userStore.superUser,
...userStore.permissions,
},
'subscribe',
)
return hasPermission(userPermissions.value, 'subscribe')
})
const hasManagePermission = computed(() => {
return hasPermission(
{
is_superuser: userStore.superUser,
...userStore.permissions,
},
'manage',
)
return hasPermission(userPermissions.value, 'manage')
})
const hasAdminPermission = computed(() => {
return hasPermission(userPermissions.value, 'admin')
})
// 是否显示合集搜索项当SEARCH_SOURCE包含themoviedb时显示
@@ -140,6 +128,7 @@ function getMenus(): NavMenu[] {
to: item.to,
header: item.header,
admin: item.admin,
permission: item.permission,
}),
)
// 设置标签页
@@ -152,6 +141,7 @@ function getMenus(): NavMenu[] {
to: `/setting?tab=${item.tab}`,
header: '',
admin: true,
permission: 'admin',
description: item.description,
}),
)
@@ -159,12 +149,6 @@ function getMenus(): NavMenu[] {
return menus
}
// 获取用户权限信息
const userPermissions = computed(() => ({
is_superuser: userStore.superUser,
...userStore.permissions,
}))
// 匹配的菜单列表
const matchedMenuItems = computed(() => {
if (!searchWord.value) return []
@@ -202,7 +186,7 @@ async function fetchInstalledPlugins() {
// 匹配的插件列表
const matchedPluginItems = computed(() => {
if (!searchWord.value) return []
if (!hasManagePermission.value) return []
if (!hasAdminPermission.value) return []
const lowerWord = (searchWord.value as string).toLowerCase()
return pluginItems.value.filter((item: Plugin) => {
if (!item.plugin_name && !item.plugin_desc) return false
@@ -222,7 +206,7 @@ async function fetchSubscribes() {
// 从接口加载用户站点偏好设置
const loadUserSitePreferences = async () => {
try {
const result = await api.get('system/setting/IndexerSites')
const result = await api.get('system/setting/public/IndexerSites')
if (result && result.data && result.data.value) {
selectedSites.value = result.data.value
return
@@ -259,7 +243,7 @@ const matchedSubscribeItems = computed(() => {
if (!hasSubscribePermission.value) return []
const lowerWord = (searchWord.value as string).toLowerCase()
return SubscribeItems.value.filter((item: Subscribe) => {
return (item.name.toLowerCase().includes(lowerWord) && (superUser || userName === item.username)) || false
return (item.name.toLowerCase().includes(lowerWord) && (userStore.superUser || userName === item.username)) || false
})
})
@@ -276,7 +260,7 @@ function searchSites(sites: number[]) {
// 搜索资源
function searchTorrent() {
if (!searchWord.value) return
if (!searchWord.value || !hasSearchPermission.value) return
// 记录搜索词
saveRecentSearches(searchWord.value)
// 跳转到搜索页面
@@ -296,7 +280,7 @@ function searchTorrent() {
// 搜索字幕资源
function searchSubtitle() {
if (!searchWord.value) return
if (!searchWord.value || !hasSearchPermission.value) return
saveRecentSearches(searchWord.value)
router.push({
path: '/resource',
@@ -314,7 +298,7 @@ function searchSubtitle() {
// 跳转媒体搜索页面
function searchMedia(searchType: string) {
// 搜索类型 media/person
if (!searchWord.value) return
if (!searchWord.value || !hasDiscoveryPermission.value) return
saveRecentSearches(searchWord.value)
router.push({
path: '/browse/media/search',
@@ -395,7 +379,7 @@ onMounted(() => {
searchWordInput.value?.focus()
}, 500)
// 根据权限加载不同的数据
if (hasManagePermission.value) {
if (hasAdminPermission.value) {
fetchInstalledPlugins()
}
if (hasSubscribePermission.value) {
@@ -437,58 +421,60 @@ onMounted(() => {
<!-- 有搜索词时显示搜索入口和匹配结果 -->
<VList lines="two" v-if="searchWord" class="search-list pa-0 py-2">
<!-- 媒体搜索入口 -->
<VListSubheader class="font-weight-medium text-uppercase px-4">
{{ t('common.media') }}
</VListSubheader>
<template v-if="hasDiscoveryPermission">
<VListSubheader class="font-weight-medium text-uppercase px-4">
{{ t('common.media') }}
</VListSubheader>
<VListItem density="comfortable" link @click="searchMedia('media')" class="search-result-item mx-2 my-1">
<template #prepend>
<div class="result-icon-wrapper">
<VIcon icon="mdi-movie-search" size="small" color="medium-emphasis" />
</div>
</template>
<VListItemTitle class="font-weight-medium text-body-2">
{{ t('recommend.categoryMovie') }}{{ t('recommend.categoryTV') }}
</VListItemTitle>
<VListItemSubtitle class="text-caption text-medium-emphasis">
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
{{ t('resource.title') }}
</VListItemSubtitle>
</VListItem>
<VListItem density="comfortable" link @click="searchMedia('media')" class="search-result-item mx-2 my-1">
<template #prepend>
<div class="result-icon-wrapper">
<VIcon icon="mdi-movie-search" size="small" color="medium-emphasis" />
</div>
</template>
<VListItemTitle class="font-weight-medium text-body-2">
{{ t('recommend.categoryMovie') }}{{ t('recommend.categoryTV') }}
</VListItemTitle>
<VListItemSubtitle class="text-caption text-medium-emphasis">
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
{{ t('resource.title') }}
</VListItemSubtitle>
</VListItem>
<VListItem
v-if="showCollectionSearch"
density="comfortable"
link
@click="searchMedia('collection')"
class="search-result-item mx-2 my-1"
>
<template #prepend>
<div class="result-icon-wrapper">
<VIcon icon="mdi-movie-filter" size="small" color="medium-emphasis" />
</div>
</template>
<VListItemTitle class="font-weight-medium text-body-2">{{
t('dialog.searchBar.collections')
}}</VListItemTitle>
<VListItemSubtitle class="text-caption text-medium-emphasis">
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
{{ t('dialog.searchBar.collectionSearch') }}
</VListItemSubtitle>
</VListItem>
<VListItem
v-if="showCollectionSearch"
density="comfortable"
link
@click="searchMedia('collection')"
class="search-result-item mx-2 my-1"
>
<template #prepend>
<div class="result-icon-wrapper">
<VIcon icon="mdi-movie-filter" size="small" color="medium-emphasis" />
</div>
</template>
<VListItemTitle class="font-weight-medium text-body-2">{{
t('dialog.searchBar.collections')
}}</VListItemTitle>
<VListItemSubtitle class="text-caption text-medium-emphasis">
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
{{ t('dialog.searchBar.collectionSearch') }}
</VListItemSubtitle>
</VListItem>
<VListItem density="comfortable" link @click="searchMedia('person')" class="search-result-item mx-2 my-1">
<template #prepend>
<div class="result-icon-wrapper">
<VIcon icon="mdi-account-search" size="small" color="medium-emphasis" />
</div>
</template>
<VListItemTitle class="font-weight-medium text-body-2">{{ t('browse.actor') }}</VListItemTitle>
<VListItemSubtitle class="text-caption text-medium-emphasis">
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
{{ t('dialog.searchBar.actorSearch') }}
</VListItemSubtitle>
</VListItem>
<VListItem density="comfortable" link @click="searchMedia('person')" class="search-result-item mx-2 my-1">
<template #prepend>
<div class="result-icon-wrapper">
<VIcon icon="mdi-account-search" size="small" color="medium-emphasis" />
</div>
</template>
<VListItemTitle class="font-weight-medium text-body-2">{{ t('browse.actor') }}</VListItemTitle>
<VListItemSubtitle class="text-caption text-medium-emphasis">
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
{{ t('dialog.searchBar.actorSearch') }}
</VListItemSubtitle>
</VListItem>
</template>
<VListItem
v-if="hasSubscribePermission"

View File

@@ -7,8 +7,14 @@ import { useDisplay } from 'vuetify'
import { useConfirm } from '@/composables/useConfirm'
import { useI18n } from 'vue-i18n'
import { qualityOptions, resolutionOptions, effectOptions } from '@/api/constants'
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
// i18n
const { t } = useI18n()
const userStore = useUserStore()
const canAdmin = computed(() =>
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
)
// 显示器宽度
const display = useDisplay()
@@ -128,6 +134,8 @@ async function loadDownloaderSetting() {
// 加载规则组
async function queryFilterRuleGroups() {
if (!canAdmin.value) return
try {
const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')
filterRuleGroups.value = result.data?.value ?? []
@@ -163,6 +171,8 @@ async function updateSubscribeInfo() {
// 设置用户设置的默认订阅规则
async function saveDefaultSubscribeConfig() {
if (!canAdmin.value) return
try {
let subscribe_config_url = ''
if (props.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
@@ -183,8 +193,8 @@ async function saveDefaultSubscribeConfig() {
async function queryDefaultSubscribeConfig() {
try {
let subscribe_config_url = ''
if (props.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
if (props.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)
@@ -260,7 +270,7 @@ async function removeSubscribe() {
// 查询下载目录
async function loadDownloadDirectories() {
try {
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) {
downloadDirectories.value = result.data.value
}

View File

@@ -4,8 +4,14 @@ import { FilterRuleGroup } from '@/api/types'
import { Handle, Position } from '@vue-flow/core'
import { useI18n } from 'vue-i18n'
import { qualityOptions, resolutionOptions, effectOptions } from '@/api/constants'
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
const { t } = useI18n()
const userStore = useUserStore()
const canAdmin = computed(() =>
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
)
defineProps({
id: {
@@ -23,6 +29,8 @@ const filterRuleGroups = ref<FilterRuleGroup[]>([])
// 加载规则组
async function queryFilterRuleGroups() {
if (!canAdmin.value) return
try {
const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')
filterRuleGroups.value = result.data?.value ?? []

View File

@@ -22,7 +22,7 @@ const storages = ref<StorageConf[]>([])
// 查询存储
async function loadStorages() {
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 ?? []
}

View File

@@ -3,8 +3,14 @@ import api from '@/api'
import { NotificationConf } from '@/api/types'
import { Handle, Position } from '@vue-flow/core'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
const { t } = useI18n()
const userStore = useUserStore()
const canAdmin = computed(() =>
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
)
defineProps({
id: {
@@ -22,6 +28,8 @@ const notifications = ref<NotificationConf[]>([])
// 调用API查询通知渠道设置
async function loadNotificationSetting() {
if (!canAdmin.value) return
try {
const result: { [key: string]: any } = await api.get('system/setting/Notifications')
notifications.value = result.data?.value ?? []

View File

@@ -12,6 +12,7 @@ import {
type ComputedRef,
type Ref,
} from 'vue'
import type { UserPermissionKey } from '@/utils/permission'
// 声明全局变量类型
declare global {
@@ -29,6 +30,7 @@ export interface DynamicButtonMenuItem {
titleParams?: Record<string, unknown>
icon?: string
color?: string
permission?: UserPermissionKey
action: () => void
}
@@ -57,11 +59,12 @@ export function useDynamicButton(options: {
icon: MaybeRefValue<string>
onClick?: () => void
menuItems?: MaybeRefValue<DynamicButtonMenuItem[] | undefined>
permission?: UserPermissionKey
show?: MaybeRefValue<boolean>
autoRegister?: boolean // 是否自动注册默认为true
}) {
// 提取配置
const { icon, onClick, menuItems, show, autoRegister = true } = options
const { icon, onClick, menuItems, permission, show, autoRegister = true } = options
// 动态按钮相关
const registerDynamicButton = inject<((button: any) => void) | null>('registerDynamicButton', null)
@@ -81,6 +84,7 @@ export function useDynamicButton(options: {
return {
icon: resolvedIcon.value,
action: onClick || (() => {}),
permission,
show: resolvedShow.value,
menuItems: buttonMenuItems && buttonMenuItems.length > 0 ? buttonMenuItems : undefined,
}
@@ -174,7 +178,7 @@ export function useDynamicButton(options: {
cleanupDynamicButton()
})
watch([resolvedIcon, resolvedShow, resolvedMenuItems], () => {
watch([resolvedIcon, resolvedShow, resolvedMenuItems, () => permission], () => {
if (!componentActive.value) return
setupDynamicButton()

View File

@@ -1,5 +1,6 @@
import type { ComputedRef, Ref } from 'vue'
import { useTabStateRestore } from '@/composables/useStateRestore'
import type { UserPermissionKey } from '@/utils/permission'
// 动态标签页相关类型
interface DynamicHeaderTabButton {
@@ -9,6 +10,7 @@ interface DynamicHeaderTabButton {
size?: string
class?: string
action?: () => void
permission?: UserPermissionKey
show?: boolean | ComputedRef<boolean>
loading?: boolean | ComputedRef<boolean>
dataAttr?: string // 用于VMenu定位的data属性

View File

@@ -1619,7 +1619,7 @@ export function useSetupWizard() {
// 加载存储设置
async function loadStorageSettings() {
try {
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 && result.data.value.length > 0) {
const directory = result.data.value[0]
wizardData.value.storage.downloadPath = directory.download_path || ''

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>

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"

View File

@@ -1,9 +1,9 @@
import { useGlobalSettingsStore } from '@/stores'
import type { NavMenuTabItem } from '@/@layouts/types'
import type { NavMenu, NavMenuTabItem } from '@/@layouts/types'
import type { Composer } from 'vue-i18n'
// 构建路由菜单,每次调用时使用当前的语言环境
export function getNavMenus(t: Composer['t']) {
export function getNavMenus(t: Composer['t']): NavMenu[] {
const globalSettingsStore = useGlobalSettingsStore()
// 检查是否为高级模式
@@ -17,7 +17,7 @@ export function getNavMenus(t: Composer['t']) {
header: t('menu.start'),
admin: false,
footer: true,
permission: 'manage',
permission: 'admin',
},
{
title: t('navItems.searchResult'),
@@ -119,7 +119,7 @@ export function getNavMenus(t: Composer['t']) {
to: '/plugins',
header: t('menu.system'),
admin: true,
permission: 'manage',
permission: 'admin',
tabs: getPluginTabs(t),
},
{
@@ -148,7 +148,7 @@ export function getNavMenus(t: Composer['t']) {
admin: true,
permission: 'admin',
tabs: getSettingTabs(t),
},
} as NavMenu,
]
: []),
]

View File

@@ -1,7 +1,8 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { configureNProgress } from '@/api/nprogress'
import { useAuthStore } from '@/stores'
import { useAuthStore, usePluginSidebarNavStore, useUserStore } from '@/stores'
import { setNavigatingState as setRequestNavigatingState } from '@/utils/requestOptimizer'
import { buildUserPermissionContext, hasPermission, type UserPermissionKey } from '@/utils/permission'
// Nprogress
configureNProgress()
@@ -15,7 +16,15 @@ const router = createRouter({
return { top: 0 }
},
routes: [
{ path: '/', redirect: '/dashboard' },
{
path: '/',
redirect: () => {
const authStore = useAuthStore()
const userStore = useUserStore()
if (!authStore.token) return '/login'
return userStore.superUser ? '/dashboard' : '/apps'
},
},
{
path: '/',
component: () => import('../layouts/default.vue'),
@@ -26,6 +35,7 @@ const router = createRouter({
meta: {
keepAlive: true,
requiresAuth: true,
permission: 'admin',
},
},
{
@@ -34,6 +44,7 @@ const router = createRouter({
meta: {
keepAlive: true,
requiresAuth: true,
permission: 'discovery',
},
},
{
@@ -42,6 +53,7 @@ const router = createRouter({
meta: {
keepAlive: true,
requiresAuth: true,
permission: 'discovery',
},
},
{
@@ -50,6 +62,7 @@ const router = createRouter({
meta: {
keepAlive: true,
requiresAuth: true,
permission: 'search',
},
},
{
@@ -59,6 +72,7 @@ const router = createRouter({
keepAlive: true,
keepAliveKey: 'subscribe-movie',
requiresAuth: true,
permission: 'subscribe',
subType: '电影',
},
},
@@ -69,6 +83,7 @@ const router = createRouter({
keepAlive: true,
keepAliveKey: 'subscribe-tv',
requiresAuth: true,
permission: 'subscribe',
subType: '电视剧',
},
},
@@ -77,6 +92,7 @@ const router = createRouter({
component: () => import('../pages/subscribe-share.vue'),
meta: {
requiresAuth: true,
permission: 'subscribe',
},
},
{
@@ -85,6 +101,7 @@ const router = createRouter({
meta: {
keepAlive: true,
requiresAuth: true,
permission: 'manage',
},
},
{
@@ -93,6 +110,7 @@ const router = createRouter({
meta: {
keepAlive: true,
requiresAuth: true,
permission: 'subscribe',
},
},
{
@@ -101,6 +119,7 @@ const router = createRouter({
meta: {
keepAlive: true,
requiresAuth: true,
permission: 'manage',
},
},
{
@@ -109,6 +128,7 @@ const router = createRouter({
meta: {
keepAlive: true,
requiresAuth: true,
permission: 'manage',
hideFooter: true,
},
},
@@ -118,6 +138,7 @@ const router = createRouter({
meta: {
keepAlive: true,
requiresAuth: true,
permission: 'manage',
},
},
{
@@ -126,6 +147,7 @@ const router = createRouter({
meta: {
keepAlive: true,
requiresAuth: true,
permission: 'admin',
},
},
{
@@ -142,6 +164,7 @@ const router = createRouter({
meta: {
keepAlive: true,
requiresAuth: true,
permission: 'admin',
},
},
{
@@ -158,6 +181,7 @@ const router = createRouter({
meta: {
keepAlive: true,
requiresAuth: true,
permission: 'admin',
},
},
{
@@ -166,6 +190,7 @@ const router = createRouter({
props: true,
meta: {
requiresAuth: true,
permission: 'discovery',
},
},
{
@@ -189,6 +214,7 @@ const router = createRouter({
component: () => import('../pages/media.vue'),
meta: {
requiresAuth: true,
permission: 'discovery',
},
},
{
@@ -197,6 +223,7 @@ const router = createRouter({
meta: {
keepAlive: true,
requiresAuth: true,
permission: 'manage',
hideFooter: true,
},
},
@@ -223,6 +250,7 @@ const router = createRouter({
component: () => import('../pages/setup.vue'),
meta: {
requiresAuth: true,
permission: 'admin',
},
},
{
@@ -234,6 +262,24 @@ const router = createRouter({
],
})
async function getRoutePermission(to: any): Promise<UserPermissionKey | undefined> {
if (to.meta.permission) {
return to.meta.permission as UserPermissionKey
}
if (to.name !== 'plugin-app') {
return undefined
}
const pluginId = String(to.params.pluginId || '')
const navKey = String(to.params.navKey || 'main')
const pluginSidebarNavStore = usePluginSidebarNavStore()
await pluginSidebarNavStore.ensureSidebarNav()
const navItem = pluginSidebarNavStore.items.find(item => item.plugin_id === pluginId && item.nav_key === navKey)
return (navItem?.permission || undefined) as UserPermissionKey | undefined
}
// 路由导航守卫
router.beforeEach(async (to: any, from: any, next: any) => {
// 设置导航状态 - 同时中断API请求
@@ -249,6 +295,24 @@ router.beforeEach(async (to: any, from: any, next: any) => {
// 用户未登录,重定向到登录页
setRequestNavigatingState(false)
next('/login')
} else if (to.meta.requiresAuth) {
const routePermission = await getRoutePermission(to)
if (!routePermission) {
next()
return
}
const userStore = useUserStore()
const allowed = hasPermission(
buildUserPermissionContext(userStore.superUser, userStore.permissions),
routePermission,
)
if (!allowed) {
setRequestNavigatingState(false)
next('/apps')
return
}
next()
} else {
next()
}

View File

@@ -4,6 +4,20 @@ export interface UserPermissions {
search: boolean // 搜索权限
subscribe: boolean // 订阅权限
manage: boolean // 管理权限
admin?: boolean // 管理员权限,仅用于前端入口标识,实际由 is_superuser 决定
}
export type UserPermissionKey = keyof UserPermissions
export type UserPermissionContext = UserPermissions & { is_superuser?: boolean; [key: string]: unknown }
export type PermissionProtectedItem = { permission?: UserPermissionKey }
// 构造权限检查上下文,统一超级管理员标记与功能权限字段。
export function buildUserPermissionContext(isSuperuser: boolean, permissions: Partial<UserPermissions> = {}): UserPermissionContext {
return {
is_superuser: isSuperuser,
...DEFAULT_PERMISSIONS,
...permissions,
}
}
// 默认权限配置
@@ -12,6 +26,7 @@ export const DEFAULT_PERMISSIONS: UserPermissions = {
search: true,
subscribe: true,
manage: false,
admin: false,
}
// 管理员权限配置
@@ -20,44 +35,51 @@ export const ADMIN_PERMISSIONS: UserPermissions = {
search: true,
subscribe: true,
manage: true,
admin: true,
}
// 权限检查函数
export function hasPermission(userPermissions: any, permission: keyof UserPermissions): boolean {
export function hasPermission(userPermissions: any, permission: UserPermissionKey): boolean {
// 如果用户是超级用户,拥有所有权限
if (userPermissions?.is_superuser === true) {
return true
}
// admin 入口只允许超级管理员,不从普通用户 permissions 字段放行
if (permission === 'admin') {
return false
}
// 检查具体权限
const permissions = userPermissions || {}
return permissions[permission] === true
}
// 批量权限检查
export function hasAnyPermission(userPermissions: any, permissionList: (keyof UserPermissions)[]): boolean {
export function hasAnyPermission(userPermissions: any, permissionList: UserPermissionKey[]): boolean {
return permissionList.some(permission => hasPermission(userPermissions, permission))
}
// 检查是否有所有权限
export function hasAllPermissions(userPermissions: any, permissionList: (keyof UserPermissions)[]): boolean {
export function hasAllPermissions(userPermissions: any, permissionList: UserPermissionKey[]): boolean {
return permissionList.every(permission => hasPermission(userPermissions, permission))
}
// 统一检查带 permission 字段的入口,避免菜单、按钮、快捷入口各自实现判断。
export function hasItemPermission(item: PermissionProtectedItem, userPermissions: any): boolean {
if (!item.permission) {
return true
}
return hasPermission(userPermissions, item.permission)
}
// 根据权限过滤带 permission 字段的入口
export function filterItemsByPermission<T extends PermissionProtectedItem>(items: T[], userPermissions: any): T[] {
return items.filter(item => hasItemPermission(item, userPermissions))
}
// 根据权限过滤菜单项
export function filterMenusByPermission(menus: any[], userPermissions: any): any[] {
return menus.filter(menu => {
// 如果是超级用户,拥有所有权限
if (userPermissions?.is_superuser) {
return true
}
// 如果菜单没有权限要求,默认显示
if (!menu.permission) {
return true
}
// 检查用户是否拥有所需权限
return hasPermission(userPermissions, menu.permission)
})
return filterItemsByPermission(menus, userPermissions)
}

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"