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