mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-09 09:46:44 +08:00
fix: enforce permission-aware navigation
This commit is contained in:
Vendored
+2
@@ -1,5 +1,6 @@
|
|||||||
import type { Component, Ref, VNode } from 'vue'
|
import type { Component, Ref, VNode } from 'vue'
|
||||||
import type { RouteLocationRaw } from 'vue-router'
|
import type { RouteLocationRaw } from 'vue-router'
|
||||||
|
import type { UserPermissionKey } from '@/utils/permission'
|
||||||
import { ContentWidth, FooterType, NavbarType } from './enums'
|
import { ContentWidth, FooterType, NavbarType } from './enums'
|
||||||
|
|
||||||
export interface UserConfig {
|
export interface UserConfig {
|
||||||
@@ -119,6 +120,7 @@ export interface NavLink extends NavLinkProps, Partial<AclProperties> {
|
|||||||
badgeContent?: string
|
badgeContent?: string
|
||||||
badgeClass?: string
|
badgeClass?: string
|
||||||
disable?: boolean
|
disable?: boolean
|
||||||
|
permission?: UserPermissionKey
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NavMenuTabItem {
|
export interface NavMenuTabItem {
|
||||||
|
|||||||
+1
-1
@@ -98,7 +98,7 @@ const startHeartbeat = () => {
|
|||||||
heartbeatInterval = window.setInterval(async () => {
|
heartbeatInterval = window.setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
if (isLogin.value) {
|
if (isLogin.value) {
|
||||||
await api.get('dashboard/cpu')
|
await api.get('system/ping')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Heartbeat request failed:', error)
|
console.warn('Heartbeat request failed:', error)
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { storageIconDict } from '@/api/constants'
|
|||||||
import type { AxiosInstance } from 'axios'
|
import type { AxiosInstance } from 'axios'
|
||||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||||
import { usePWA } from '@/composables/usePWA'
|
import { usePWA } from '@/composables/usePWA'
|
||||||
|
import { useUserStore } from '@/stores'
|
||||||
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
|
|
||||||
// LocalStorage keys
|
// LocalStorage keys
|
||||||
const SORT_KEY = 'fileBrowser.sort'
|
const SORT_KEY = 'fileBrowser.sort'
|
||||||
@@ -41,6 +43,10 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['pathchanged'])
|
const emit = defineEmits(['pathchanged'])
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { appMode } = usePWA()
|
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 toolbarRef = ref<InstanceType<typeof FileToolbar> | null>(null)
|
||||||
|
|
||||||
const fileIcons = {
|
const fileIcons = {
|
||||||
@@ -136,11 +142,12 @@ function openNewFolderDialog() {
|
|||||||
toolbarRef.value?.openNewFolderDialog()
|
toolbarRef.value?.openNewFolderDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
const showFloatingNewFolderAction = computed(() => route.path === '/filemanager')
|
const showFloatingNewFolderAction = computed(() => route.path === '/filemanager' && canManage.value)
|
||||||
|
|
||||||
useDynamicButton({
|
useDynamicButton({
|
||||||
icon: 'mdi-folder-plus-outline',
|
icon: 'mdi-folder-plus-outline',
|
||||||
onClick: openNewFolderDialog,
|
onClick: openNewFolderDialog,
|
||||||
|
permission: 'manage',
|
||||||
show: computed(() => appMode.value && showFloatingNewFolderAction.value),
|
show: computed(() => appMode.value && showFloatingNewFolderAction.value),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import router from '@/router'
|
|||||||
import { useUserStore, useGlobalSettingsStore } from '@/stores'
|
import { useUserStore, useGlobalSettingsStore } from '@/stores'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { mediaTypeDict } from '@/api/constants'
|
import { mediaTypeDict } from '@/api/constants'
|
||||||
import { hasPermission } from '@/utils/permission'
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
import {
|
import {
|
||||||
getCachedMediaExistsStatus,
|
getCachedMediaExistsStatus,
|
||||||
@@ -45,6 +45,9 @@ const globalSettings = globalSettingsStore.globalSettings
|
|||||||
|
|
||||||
// 用户 Store
|
// 用户 Store
|
||||||
const userStore = useUserStore()
|
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()
|
const $toast = useToast()
|
||||||
@@ -143,7 +146,7 @@ async function querySites() {
|
|||||||
// 查询用户选中的站点
|
// 查询用户选中的站点
|
||||||
async function querySelectedSites() {
|
async function querySelectedSites() {
|
||||||
try {
|
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 ?? []
|
selectedSites.value = result.data?.value ?? []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
@@ -336,12 +339,11 @@ async function checkSubscribe(season: number | null) {
|
|||||||
|
|
||||||
// 查询订阅弹窗规则
|
// 查询订阅弹窗规则
|
||||||
async function queryDefaultSubscribeConfig() {
|
async function queryDefaultSubscribeConfig() {
|
||||||
// 非管理员不显示
|
if (!canSubscribe.value) return false
|
||||||
if (!userStore.superUser) return false
|
|
||||||
try {
|
try {
|
||||||
let subscribe_config_url = ''
|
let subscribe_config_url = ''
|
||||||
if (props.media?.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
|
if (props.media?.type === '电影') subscribe_config_url = 'system/setting/public/DefaultMovieSubscribeConfig'
|
||||||
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
|
else subscribe_config_url = 'system/setting/public/DefaultTvSubscribeConfig'
|
||||||
const result: { [key: string]: any } = await api.get(subscribe_config_url)
|
const result: { [key: string]: any } = await api.get(subscribe_config_url)
|
||||||
if (result.data?.value) return result.data.value.show_edit_dialog
|
if (result.data?.value) return result.data.value.show_edit_dialog
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -534,7 +536,7 @@ onBeforeUnmount(() => {
|
|||||||
<div v-if="props.media?.collection_id" class="mb-3" @click.stop=""></div>
|
<div v-if="props.media?.collection_id" class="mb-3" @click.stop=""></div>
|
||||||
<div v-else class="flex align-center justify-between">
|
<div v-else class="flex align-center justify-between">
|
||||||
<IconBtn
|
<IconBtn
|
||||||
v-if="hasPermission({ is_superuser: userStore.superUser, ...userStore.permissions }, 'search')"
|
v-if="canSearch"
|
||||||
icon="mdi-magnify"
|
icon="mdi-magnify"
|
||||||
color="white"
|
color="white"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -542,6 +544,7 @@ onBeforeUnmount(() => {
|
|||||||
/>
|
/>
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<IconBtn
|
<IconBtn
|
||||||
|
v-if="canSubscribe"
|
||||||
icon="mdi-heart"
|
icon="mdi-heart"
|
||||||
:color="isSubscribed ? 'error' : 'white'"
|
:color="isSubscribed ? 'error' : 'white'"
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ const buttonText = computed(() =>
|
|||||||
// 加载目录设置
|
// 加载目录设置
|
||||||
async function loadDirectories() {
|
async function loadDirectories() {
|
||||||
try {
|
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 ?? []
|
directories.value = result.data?.value ?? []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const buttonText = computed(() =>
|
|||||||
// 加载目录设置
|
// 加载目录设置
|
||||||
async function loadDirectories() {
|
async function loadDirectories() {
|
||||||
try {
|
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 ?? []
|
directories.value = result.data?.value ?? []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ function toggleExpand() {
|
|||||||
// 加载follow用户列表
|
// 加载follow用户列表
|
||||||
async function queryFollowUsers() {
|
async function queryFollowUsers() {
|
||||||
try {
|
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 ?? []
|
followUsers.value = result.data?.value ?? []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ function switchEditorMode(mode: EditorMode | undefined) {
|
|||||||
/** 加载插件市场仓库配置。 */
|
/** 加载插件市场仓库配置。 */
|
||||||
async function queryMarketRepoSetting() {
|
async function queryMarketRepoSetting() {
|
||||||
try {
|
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) {
|
if (result && result.data && result.data.value) {
|
||||||
repoList.value = parseRepoInput(result.data.value).repos
|
repoList.value = parseRepoInput(result.data.value).repos
|
||||||
syncTextFromList()
|
syncTextFromList()
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ let episodeGroupQueryTimer: ReturnType<typeof setTimeout> | undefined
|
|||||||
// 查询存储
|
// 查询存储
|
||||||
async function loadStorages() {
|
async function loadStorages() {
|
||||||
try {
|
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 ?? []
|
storages.value = result.data?.value ?? []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -292,7 +292,7 @@ const directories = ref<TransferDirectoryConf[]>([])
|
|||||||
// 查询目录
|
// 查询目录
|
||||||
async function loadDirectories() {
|
async function loadDirectories() {
|
||||||
try {
|
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 ?? []
|
directories.value = result.data?.value ?? []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
@@ -848,7 +848,7 @@ async function requestManualTransfer<T = any>(
|
|||||||
// 加载剧集格式规则配置状态,用于决定是否允许自动推荐。
|
// 加载剧集格式规则配置状态,用于决定是否允许自动推荐。
|
||||||
async function loadEpisodeFormatRuleConfiguration() {
|
async function loadEpisodeFormatRuleConfiguration() {
|
||||||
try {
|
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)
|
episodeFormatRuleConfigured.value = Boolean(result.data?.value?.length)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useUserStore, useGlobalSettingsStore } from '@/stores'
|
|||||||
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
|
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { hasPermission, filterMenusByPermission } from '@/utils/permission'
|
import { buildUserPermissionContext, hasPermission, filterMenusByPermission } from '@/utils/permission'
|
||||||
|
|
||||||
// 显示器宽度
|
// 显示器宽度
|
||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
@@ -30,41 +30,29 @@ const userStore = useUserStore()
|
|||||||
const globalSettingsStore = useGlobalSettingsStore()
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
const globalSettings = globalSettingsStore.globalSettings
|
const globalSettings = globalSettingsStore.globalSettings
|
||||||
|
|
||||||
// 超级用户
|
|
||||||
const superUser = userStore.superUser
|
|
||||||
|
|
||||||
// 当前用户名
|
// 当前用户名
|
||||||
const userName = userStore.userName
|
const userName = userStore.userName
|
||||||
|
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||||
|
|
||||||
// 权限检查
|
// 权限检查
|
||||||
const hasSearchPermission = computed(() => {
|
const hasSearchPermission = computed(() => {
|
||||||
return hasPermission(
|
return hasPermission(userPermissions.value, 'search')
|
||||||
{
|
})
|
||||||
is_superuser: userStore.superUser,
|
|
||||||
...userStore.permissions,
|
const hasDiscoveryPermission = computed(() => {
|
||||||
},
|
return hasPermission(userPermissions.value, 'discovery')
|
||||||
'search',
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasSubscribePermission = computed(() => {
|
const hasSubscribePermission = computed(() => {
|
||||||
return hasPermission(
|
return hasPermission(userPermissions.value, 'subscribe')
|
||||||
{
|
|
||||||
is_superuser: userStore.superUser,
|
|
||||||
...userStore.permissions,
|
|
||||||
},
|
|
||||||
'subscribe',
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasManagePermission = computed(() => {
|
const hasManagePermission = computed(() => {
|
||||||
return hasPermission(
|
return hasPermission(userPermissions.value, 'manage')
|
||||||
{
|
})
|
||||||
is_superuser: userStore.superUser,
|
|
||||||
...userStore.permissions,
|
const hasAdminPermission = computed(() => {
|
||||||
},
|
return hasPermission(userPermissions.value, 'admin')
|
||||||
'manage',
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 是否显示合集搜索项(当SEARCH_SOURCE包含themoviedb时显示)
|
// 是否显示合集搜索项(当SEARCH_SOURCE包含themoviedb时显示)
|
||||||
@@ -140,6 +128,7 @@ function getMenus(): NavMenu[] {
|
|||||||
to: item.to,
|
to: item.to,
|
||||||
header: item.header,
|
header: item.header,
|
||||||
admin: item.admin,
|
admin: item.admin,
|
||||||
|
permission: item.permission,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
// 设置标签页
|
// 设置标签页
|
||||||
@@ -152,6 +141,7 @@ function getMenus(): NavMenu[] {
|
|||||||
to: `/setting?tab=${item.tab}`,
|
to: `/setting?tab=${item.tab}`,
|
||||||
header: '',
|
header: '',
|
||||||
admin: true,
|
admin: true,
|
||||||
|
permission: 'admin',
|
||||||
description: item.description,
|
description: item.description,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -159,12 +149,6 @@ function getMenus(): NavMenu[] {
|
|||||||
return menus
|
return menus
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取用户权限信息
|
|
||||||
const userPermissions = computed(() => ({
|
|
||||||
is_superuser: userStore.superUser,
|
|
||||||
...userStore.permissions,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// 匹配的菜单列表
|
// 匹配的菜单列表
|
||||||
const matchedMenuItems = computed(() => {
|
const matchedMenuItems = computed(() => {
|
||||||
if (!searchWord.value) return []
|
if (!searchWord.value) return []
|
||||||
@@ -202,7 +186,7 @@ async function fetchInstalledPlugins() {
|
|||||||
// 匹配的插件列表
|
// 匹配的插件列表
|
||||||
const matchedPluginItems = computed(() => {
|
const matchedPluginItems = computed(() => {
|
||||||
if (!searchWord.value) return []
|
if (!searchWord.value) return []
|
||||||
if (!hasManagePermission.value) return []
|
if (!hasAdminPermission.value) return []
|
||||||
const lowerWord = (searchWord.value as string).toLowerCase()
|
const lowerWord = (searchWord.value as string).toLowerCase()
|
||||||
return pluginItems.value.filter((item: Plugin) => {
|
return pluginItems.value.filter((item: Plugin) => {
|
||||||
if (!item.plugin_name && !item.plugin_desc) return false
|
if (!item.plugin_name && !item.plugin_desc) return false
|
||||||
@@ -222,7 +206,7 @@ async function fetchSubscribes() {
|
|||||||
// 从接口加载用户站点偏好设置
|
// 从接口加载用户站点偏好设置
|
||||||
const loadUserSitePreferences = async () => {
|
const loadUserSitePreferences = async () => {
|
||||||
try {
|
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) {
|
if (result && result.data && result.data.value) {
|
||||||
selectedSites.value = result.data.value
|
selectedSites.value = result.data.value
|
||||||
return
|
return
|
||||||
@@ -259,7 +243,7 @@ const matchedSubscribeItems = computed(() => {
|
|||||||
if (!hasSubscribePermission.value) return []
|
if (!hasSubscribePermission.value) return []
|
||||||
const lowerWord = (searchWord.value as string).toLowerCase()
|
const lowerWord = (searchWord.value as string).toLowerCase()
|
||||||
return SubscribeItems.value.filter((item: Subscribe) => {
|
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() {
|
function searchTorrent() {
|
||||||
if (!searchWord.value) return
|
if (!searchWord.value || !hasSearchPermission.value) return
|
||||||
// 记录搜索词
|
// 记录搜索词
|
||||||
saveRecentSearches(searchWord.value)
|
saveRecentSearches(searchWord.value)
|
||||||
// 跳转到搜索页面
|
// 跳转到搜索页面
|
||||||
@@ -296,7 +280,7 @@ function searchTorrent() {
|
|||||||
|
|
||||||
// 搜索字幕资源
|
// 搜索字幕资源
|
||||||
function searchSubtitle() {
|
function searchSubtitle() {
|
||||||
if (!searchWord.value) return
|
if (!searchWord.value || !hasSearchPermission.value) return
|
||||||
saveRecentSearches(searchWord.value)
|
saveRecentSearches(searchWord.value)
|
||||||
router.push({
|
router.push({
|
||||||
path: '/resource',
|
path: '/resource',
|
||||||
@@ -314,7 +298,7 @@ function searchSubtitle() {
|
|||||||
// 跳转媒体搜索页面
|
// 跳转媒体搜索页面
|
||||||
function searchMedia(searchType: string) {
|
function searchMedia(searchType: string) {
|
||||||
// 搜索类型 media/person
|
// 搜索类型 media/person
|
||||||
if (!searchWord.value) return
|
if (!searchWord.value || !hasDiscoveryPermission.value) return
|
||||||
saveRecentSearches(searchWord.value)
|
saveRecentSearches(searchWord.value)
|
||||||
router.push({
|
router.push({
|
||||||
path: '/browse/media/search',
|
path: '/browse/media/search',
|
||||||
@@ -395,7 +379,7 @@ onMounted(() => {
|
|||||||
searchWordInput.value?.focus()
|
searchWordInput.value?.focus()
|
||||||
}, 500)
|
}, 500)
|
||||||
// 根据权限加载不同的数据
|
// 根据权限加载不同的数据
|
||||||
if (hasManagePermission.value) {
|
if (hasAdminPermission.value) {
|
||||||
fetchInstalledPlugins()
|
fetchInstalledPlugins()
|
||||||
}
|
}
|
||||||
if (hasSubscribePermission.value) {
|
if (hasSubscribePermission.value) {
|
||||||
@@ -437,6 +421,7 @@ onMounted(() => {
|
|||||||
<!-- 有搜索词时显示搜索入口和匹配结果 -->
|
<!-- 有搜索词时显示搜索入口和匹配结果 -->
|
||||||
<VList lines="two" v-if="searchWord" class="search-list pa-0 py-2">
|
<VList lines="two" v-if="searchWord" class="search-list pa-0 py-2">
|
||||||
<!-- 媒体搜索入口 -->
|
<!-- 媒体搜索入口 -->
|
||||||
|
<template v-if="hasDiscoveryPermission">
|
||||||
<VListSubheader class="font-weight-medium text-uppercase px-4">
|
<VListSubheader class="font-weight-medium text-uppercase px-4">
|
||||||
{{ t('common.media') }}
|
{{ t('common.media') }}
|
||||||
</VListSubheader>
|
</VListSubheader>
|
||||||
@@ -489,6 +474,7 @@ onMounted(() => {
|
|||||||
{{ t('dialog.searchBar.actorSearch') }}
|
{{ t('dialog.searchBar.actorSearch') }}
|
||||||
</VListItemSubtitle>
|
</VListItemSubtitle>
|
||||||
</VListItem>
|
</VListItem>
|
||||||
|
</template>
|
||||||
|
|
||||||
<VListItem
|
<VListItem
|
||||||
v-if="hasSubscribePermission"
|
v-if="hasSubscribePermission"
|
||||||
|
|||||||
@@ -7,8 +7,14 @@ import { useDisplay } from 'vuetify'
|
|||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { qualityOptions, resolutionOptions, effectOptions } from '@/api/constants'
|
import { qualityOptions, resolutionOptions, effectOptions } from '@/api/constants'
|
||||||
|
import { useUserStore } from '@/stores'
|
||||||
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
// i18n
|
// i18n
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const canAdmin = computed(() =>
|
||||||
|
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
|
||||||
|
)
|
||||||
|
|
||||||
// 显示器宽度
|
// 显示器宽度
|
||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
@@ -128,6 +134,8 @@ async function loadDownloaderSetting() {
|
|||||||
|
|
||||||
// 加载规则组
|
// 加载规则组
|
||||||
async function queryFilterRuleGroups() {
|
async function queryFilterRuleGroups() {
|
||||||
|
if (!canAdmin.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')
|
const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')
|
||||||
filterRuleGroups.value = result.data?.value ?? []
|
filterRuleGroups.value = result.data?.value ?? []
|
||||||
@@ -163,6 +171,8 @@ async function updateSubscribeInfo() {
|
|||||||
|
|
||||||
// 设置用户设置的默认订阅规则
|
// 设置用户设置的默认订阅规则
|
||||||
async function saveDefaultSubscribeConfig() {
|
async function saveDefaultSubscribeConfig() {
|
||||||
|
if (!canAdmin.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let subscribe_config_url = ''
|
let subscribe_config_url = ''
|
||||||
if (props.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
|
if (props.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
|
||||||
@@ -183,8 +193,8 @@ async function saveDefaultSubscribeConfig() {
|
|||||||
async function queryDefaultSubscribeConfig() {
|
async function queryDefaultSubscribeConfig() {
|
||||||
try {
|
try {
|
||||||
let subscribe_config_url = ''
|
let subscribe_config_url = ''
|
||||||
if (props.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
|
if (props.type === '电影') subscribe_config_url = 'system/setting/public/DefaultMovieSubscribeConfig'
|
||||||
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
|
else subscribe_config_url = 'system/setting/public/DefaultTvSubscribeConfig'
|
||||||
|
|
||||||
const result: { [key: string]: any } = await api.get(subscribe_config_url)
|
const result: { [key: string]: any } = await api.get(subscribe_config_url)
|
||||||
|
|
||||||
@@ -260,7 +270,7 @@ async function removeSubscribe() {
|
|||||||
// 查询下载目录
|
// 查询下载目录
|
||||||
async function loadDownloadDirectories() {
|
async function loadDownloadDirectories() {
|
||||||
try {
|
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) {
|
if (result.success && result.data?.value) {
|
||||||
downloadDirectories.value = 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 { Handle, Position } from '@vue-flow/core'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { qualityOptions, resolutionOptions, effectOptions } from '@/api/constants'
|
import { qualityOptions, resolutionOptions, effectOptions } from '@/api/constants'
|
||||||
|
import { useUserStore } from '@/stores'
|
||||||
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const canAdmin = computed(() =>
|
||||||
|
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
|
||||||
|
)
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
id: {
|
id: {
|
||||||
@@ -23,6 +29,8 @@ const filterRuleGroups = ref<FilterRuleGroup[]>([])
|
|||||||
|
|
||||||
// 加载规则组
|
// 加载规则组
|
||||||
async function queryFilterRuleGroups() {
|
async function queryFilterRuleGroups() {
|
||||||
|
if (!canAdmin.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')
|
const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')
|
||||||
filterRuleGroups.value = result.data?.value ?? []
|
filterRuleGroups.value = result.data?.value ?? []
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const storages = ref<StorageConf[]>([])
|
|||||||
|
|
||||||
// 查询存储
|
// 查询存储
|
||||||
async function loadStorages() {
|
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 ?? []
|
storages.value = result.data?.value ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,14 @@ import api from '@/api'
|
|||||||
import { NotificationConf } from '@/api/types'
|
import { NotificationConf } from '@/api/types'
|
||||||
import { Handle, Position } from '@vue-flow/core'
|
import { Handle, Position } from '@vue-flow/core'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useUserStore } from '@/stores'
|
||||||
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const canAdmin = computed(() =>
|
||||||
|
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
|
||||||
|
)
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
id: {
|
id: {
|
||||||
@@ -22,6 +28,8 @@ const notifications = ref<NotificationConf[]>([])
|
|||||||
|
|
||||||
// 调用API查询通知渠道设置
|
// 调用API查询通知渠道设置
|
||||||
async function loadNotificationSetting() {
|
async function loadNotificationSetting() {
|
||||||
|
if (!canAdmin.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('system/setting/Notifications')
|
const result: { [key: string]: any } = await api.get('system/setting/Notifications')
|
||||||
notifications.value = result.data?.value ?? []
|
notifications.value = result.data?.value ?? []
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type ComputedRef,
|
type ComputedRef,
|
||||||
type Ref,
|
type Ref,
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
|
import type { UserPermissionKey } from '@/utils/permission'
|
||||||
|
|
||||||
// 声明全局变量类型
|
// 声明全局变量类型
|
||||||
declare global {
|
declare global {
|
||||||
@@ -29,6 +30,7 @@ export interface DynamicButtonMenuItem {
|
|||||||
titleParams?: Record<string, unknown>
|
titleParams?: Record<string, unknown>
|
||||||
icon?: string
|
icon?: string
|
||||||
color?: string
|
color?: string
|
||||||
|
permission?: UserPermissionKey
|
||||||
action: () => void
|
action: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,11 +59,12 @@ export function useDynamicButton(options: {
|
|||||||
icon: MaybeRefValue<string>
|
icon: MaybeRefValue<string>
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
menuItems?: MaybeRefValue<DynamicButtonMenuItem[] | undefined>
|
menuItems?: MaybeRefValue<DynamicButtonMenuItem[] | undefined>
|
||||||
|
permission?: UserPermissionKey
|
||||||
show?: MaybeRefValue<boolean>
|
show?: MaybeRefValue<boolean>
|
||||||
autoRegister?: boolean // 是否自动注册,默认为true
|
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)
|
const registerDynamicButton = inject<((button: any) => void) | null>('registerDynamicButton', null)
|
||||||
@@ -81,6 +84,7 @@ export function useDynamicButton(options: {
|
|||||||
return {
|
return {
|
||||||
icon: resolvedIcon.value,
|
icon: resolvedIcon.value,
|
||||||
action: onClick || (() => {}),
|
action: onClick || (() => {}),
|
||||||
|
permission,
|
||||||
show: resolvedShow.value,
|
show: resolvedShow.value,
|
||||||
menuItems: buttonMenuItems && buttonMenuItems.length > 0 ? buttonMenuItems : undefined,
|
menuItems: buttonMenuItems && buttonMenuItems.length > 0 ? buttonMenuItems : undefined,
|
||||||
}
|
}
|
||||||
@@ -174,7 +178,7 @@ export function useDynamicButton(options: {
|
|||||||
cleanupDynamicButton()
|
cleanupDynamicButton()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch([resolvedIcon, resolvedShow, resolvedMenuItems], () => {
|
watch([resolvedIcon, resolvedShow, resolvedMenuItems, () => permission], () => {
|
||||||
if (!componentActive.value) return
|
if (!componentActive.value) return
|
||||||
|
|
||||||
setupDynamicButton()
|
setupDynamicButton()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ComputedRef, Ref } from 'vue'
|
import type { ComputedRef, Ref } from 'vue'
|
||||||
import { useTabStateRestore } from '@/composables/useStateRestore'
|
import { useTabStateRestore } from '@/composables/useStateRestore'
|
||||||
|
import type { UserPermissionKey } from '@/utils/permission'
|
||||||
|
|
||||||
// 动态标签页相关类型
|
// 动态标签页相关类型
|
||||||
interface DynamicHeaderTabButton {
|
interface DynamicHeaderTabButton {
|
||||||
@@ -9,6 +10,7 @@ interface DynamicHeaderTabButton {
|
|||||||
size?: string
|
size?: string
|
||||||
class?: string
|
class?: string
|
||||||
action?: () => void
|
action?: () => void
|
||||||
|
permission?: UserPermissionKey
|
||||||
show?: boolean | ComputedRef<boolean>
|
show?: boolean | ComputedRef<boolean>
|
||||||
loading?: boolean | ComputedRef<boolean>
|
loading?: boolean | ComputedRef<boolean>
|
||||||
dataAttr?: string // 用于VMenu定位的data属性
|
dataAttr?: string // 用于VMenu定位的data属性
|
||||||
|
|||||||
@@ -1619,7 +1619,7 @@ export function useSetupWizard() {
|
|||||||
// 加载存储设置
|
// 加载存储设置
|
||||||
async function loadStorageSettings() {
|
async function loadStorageSettings() {
|
||||||
try {
|
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) {
|
if (result.success && result.data?.value && result.data.value.length > 0) {
|
||||||
const directory = result.data.value[0]
|
const directory = result.data.value[0]
|
||||||
wizardData.value.storage.downloadPath = directory.download_path || ''
|
wizardData.value.storage.downloadPath = directory.download_path || ''
|
||||||
|
|||||||
@@ -16,7 +16,14 @@ import { NavMenu } from '@/@layouts/types'
|
|||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
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 { onUnreadMessage } from '@/utils/badge'
|
||||||
import { usePullDownGesture } from '@/composables/usePullDownGesture'
|
import { usePullDownGesture } from '@/composables/usePullDownGesture'
|
||||||
import { usePWA } from '@/composables/usePWA'
|
import { usePWA } from '@/composables/usePWA'
|
||||||
@@ -41,17 +48,12 @@ const themeLayout = ref(readThemeCustomizerSettings().layout)
|
|||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const pluginSidebarNavStore = usePluginSidebarNavStore()
|
const pluginSidebarNavStore = usePluginSidebarNavStore()
|
||||||
|
|
||||||
// 响应式的超级用户状态
|
|
||||||
const superUser = computed(() => userStore.superUser)
|
|
||||||
|
|
||||||
// ShortcutBar 引用
|
// ShortcutBar 引用
|
||||||
const shortcutBarRef = ref<InstanceType<typeof ShortcutBar> | null>(null)
|
const shortcutBarRef = ref<InstanceType<typeof ShortcutBar> | null>(null)
|
||||||
|
|
||||||
// 获取用户权限信息
|
// 获取用户权限信息
|
||||||
const userPermissions = computed(() => ({
|
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||||
is_superuser: userStore.superUser,
|
const canAdmin = computed(() => hasPermission(userPermissions.value, 'admin'))
|
||||||
...userStore.permissions,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// 开始菜单项
|
// 开始菜单项
|
||||||
const startMenus = ref<NavMenu[]>([])
|
const startMenus = ref<NavMenu[]>([])
|
||||||
@@ -112,6 +114,7 @@ interface DynamicHeaderTabButton {
|
|||||||
size?: string
|
size?: string
|
||||||
class?: string
|
class?: string
|
||||||
action?: () => void
|
action?: () => void
|
||||||
|
permission?: UserPermissionKey
|
||||||
show?: boolean | ComputedRef<boolean>
|
show?: boolean | ComputedRef<boolean>
|
||||||
loading?: boolean | ComputedRef<boolean>
|
loading?: boolean | ComputedRef<boolean>
|
||||||
dataAttr?: string
|
dataAttr?: string
|
||||||
@@ -196,10 +199,19 @@ const hasDynamicHeaderTab = computed(() => {
|
|||||||
// 水平布局下动态标签页会并入顶部导航三级菜单,不再额外显示标签页栏。
|
// 水平布局下动态标签页会并入顶部导航三级菜单,不再额外显示标签页栏。
|
||||||
const showDynamicHeaderTab = computed(() => hasDynamicHeaderTab.value && !showHorizontalThemeNav.value)
|
const showDynamicHeaderTab = computed(() => hasDynamicHeaderTab.value && !showHorizontalThemeNav.value)
|
||||||
|
|
||||||
const visibleHorizontalHeaderButtons = computed(() => {
|
const visibleDynamicHeaderButtons = computed(() => {
|
||||||
if (!showHorizontalThemeNav.value || !hasDynamicHeaderTab.value) return []
|
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页面
|
// 检查是否在dashboard页面
|
||||||
const isDashboard = route.path === '/dashboard' || route.path === '/'
|
const isDashboard = route.path === '/dashboard' || route.path === '/'
|
||||||
// 检查是否是管理员
|
// 检查是否是管理员
|
||||||
const isAdmin = superUser.value
|
const isAdmin = canAdmin.value
|
||||||
// 检查插件快速访问面板是否已显示
|
// 检查插件快速访问面板是否已显示
|
||||||
const quickAccessOpen = showPluginQuickAccess.value
|
const quickAccessOpen = showPluginQuickAccess.value
|
||||||
// 检查是否离线
|
// 检查是否离线
|
||||||
@@ -323,6 +335,12 @@ function resolveHeaderButtonLoading(button: DynamicHeaderTabButton) {
|
|||||||
return resolveMaybeRefValue(button.loading, false)
|
return resolveMaybeRefValue(button.loading, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleHeaderButtonClick(button: DynamicHeaderTabButton) {
|
||||||
|
if (!hasItemPermission(button, userPermissions.value)) return
|
||||||
|
|
||||||
|
button.action?.()
|
||||||
|
}
|
||||||
|
|
||||||
function getHorizontalTabIcon(tab: DynamicHeaderTabItem) {
|
function getHorizontalTabIcon(tab: DynamicHeaderTabItem) {
|
||||||
const icon = tab.icon?.trim()
|
const icon = tab.icon?.trim()
|
||||||
|
|
||||||
@@ -366,7 +384,7 @@ function applyPendingHorizontalTab() {
|
|||||||
|
|
||||||
// 处理未读消息事件
|
// 处理未读消息事件
|
||||||
function handleUnreadMessage(count: number) {
|
function handleUnreadMessage(count: number) {
|
||||||
if (superUser.value && count > 0) {
|
if (canAdmin.value && count > 0) {
|
||||||
// 延迟一点时间确保组件已渲染
|
// 延迟一点时间确保组件已渲染
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (shortcutBarRef.value && typeof shortcutBarRef.value.openMessageDialog === 'function') {
|
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 d-flex h-14 align-center mx-1"
|
||||||
:class="{ 'theme-navbar-row--horizontal': showHorizontalThemeNav }"
|
: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__mark" v-html="logo" />
|
||||||
<span class="theme-horizontal-logo__text">MOVIEPILOT</span>
|
<span class="theme-horizontal-logo__text">MOVIEPILOT</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
@@ -503,7 +521,7 @@ onMounted(async () => {
|
|||||||
<!-- 👉 Horizontal Search Icon -->
|
<!-- 👉 Horizontal Search Icon -->
|
||||||
<SearchBar v-if="showHorizontalThemeNav" icon-only />
|
<SearchBar v-if="showHorizontalThemeNav" icon-only />
|
||||||
<!-- 👉 Shortcuts -->
|
<!-- 👉 Shortcuts -->
|
||||||
<ShortcutBar v-if="superUser" ref="shortcutBarRef" />
|
<ShortcutBar v-if="canAdmin" ref="shortcutBarRef" />
|
||||||
<!-- 👉 Notification -->
|
<!-- 👉 Notification -->
|
||||||
<UserNofification />
|
<UserNofification />
|
||||||
<!-- 👉 UserProfile -->
|
<!-- 👉 UserProfile -->
|
||||||
@@ -597,7 +615,7 @@ onMounted(async () => {
|
|||||||
:class="button.class || 'settings-icon-button'"
|
:class="button.class || 'settings-icon-button'"
|
||||||
:loading="resolveHeaderButtonLoading(button)"
|
:loading="resolveHeaderButtonLoading(button)"
|
||||||
:data-menu-activator="button.dataAttr"
|
:data-menu-activator="button.dataAttr"
|
||||||
@click="button.action"
|
@click="handleHeaderButtonClick(button)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -650,17 +668,16 @@ onMounted(async () => {
|
|||||||
@update:model-value="handleTabChange"
|
@update:model-value="handleTabChange"
|
||||||
>
|
>
|
||||||
<template #append>
|
<template #append>
|
||||||
<template v-for="button in dynamicHeaderTab!.appendButtons" :key="button.icon">
|
<template v-for="button in visibleDynamicHeaderButtons" :key="button.icon">
|
||||||
<VBtn
|
<VBtn
|
||||||
v-if="typeof button.show === 'boolean' ? button.show !== false : (button.show as any)?.value !== false"
|
|
||||||
:icon="button.icon"
|
:icon="button.icon"
|
||||||
:variant="button.variant || 'text'"
|
:variant="button.variant || 'text'"
|
||||||
:color="typeof button.color === 'string' ? button.color : (button.color as any)?.value || 'gray'"
|
:color="resolveHeaderButtonColor(button)"
|
||||||
:size="button.size || 'default'"
|
:size="button.size || 'default'"
|
||||||
:class="button.class || 'settings-icon-button'"
|
: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"
|
:data-menu-activator="button.dataAttr"
|
||||||
@click="button.action"
|
@click="handleHeaderButtonClick(button)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useDisplay } from 'vuetify'
|
|||||||
import { NavMenu } from '@/@layouts/types'
|
import { NavMenu } from '@/@layouts/types'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useUserStore } from '@/stores'
|
import { useUserStore } from '@/stores'
|
||||||
import { filterMenusByPermission } from '@/utils/permission'
|
import { buildUserPermissionContext, filterItemsByPermission, filterMenusByPermission, hasItemPermission } from '@/utils/permission'
|
||||||
import { usePWA } from '@/composables/usePWA'
|
import { usePWA } from '@/composables/usePWA'
|
||||||
import type { DynamicButtonMenuItem } from '@/composables/useDynamicButton'
|
import type { DynamicButtonMenuItem } from '@/composables/useDynamicButton'
|
||||||
|
|
||||||
@@ -42,10 +42,7 @@ const userPermissions = computed(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return buildUserPermissionContext(userStore.superUser, userStore.permissions)
|
||||||
is_superuser: userStore.superUser,
|
|
||||||
...userStore.permissions,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 获取导航菜单
|
// 获取导航菜单
|
||||||
@@ -119,6 +116,7 @@ watch(
|
|||||||
interface DynamicButton {
|
interface DynamicButton {
|
||||||
icon: string
|
icon: string
|
||||||
action: () => void
|
action: () => void
|
||||||
|
permission?: DynamicButtonMenuItem['permission']
|
||||||
show: boolean
|
show: boolean
|
||||||
routePath?: string // 添加路径属性,用于标识哪个路由注册的
|
routePath?: string // 添加路径属性,用于标识哪个路由注册的
|
||||||
menuItems?: DynamicButtonMenuItem[]
|
menuItems?: DynamicButtonMenuItem[]
|
||||||
@@ -166,12 +164,17 @@ const showDynamicButton = computed(() => {
|
|||||||
return (
|
return (
|
||||||
dynamicButton.value &&
|
dynamicButton.value &&
|
||||||
dynamicButton.value.show &&
|
dynamicButton.value.show &&
|
||||||
|
hasItemPermission(dynamicButton.value, userPermissions.value) &&
|
||||||
// 确保只在注册的路由路径下显示按钮
|
// 确保只在注册的路由路径下显示按钮
|
||||||
(!dynamicButton.value.routePath || dynamicButton.value.routePath === route.path)
|
(!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> = {
|
const legacyDynamicMenuTitleKeyMap: Record<string, string> = {
|
||||||
'components.subscribeHistory.title': 'dialog.subscribeHistory.title',
|
'components.subscribeHistory.title': 'dialog.subscribeHistory.title',
|
||||||
@@ -194,6 +197,18 @@ function resolveDynamicMenuItemTitle(item: DynamicButtonMenuItem) {
|
|||||||
|
|
||||||
return looksLikeI18nKey ? t(normalizedTitleKey, item.titleParams as any) : item.title
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -257,7 +272,7 @@ function resolveDynamicMenuItemTitle(item: DynamicButtonMenuItem) {
|
|||||||
icon
|
icon
|
||||||
variant="text"
|
variant="text"
|
||||||
:ripple="false"
|
:ripple="false"
|
||||||
@click="!hasDynamicButtonMenu && dynamicButton?.action()"
|
@click="!hasDynamicButtonMenu && handleDynamicButtonClick()"
|
||||||
rounded="pill"
|
rounded="pill"
|
||||||
class="footer-nav-btn"
|
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>
|
<VMenu v-if="hasDynamicButtonMenu" activator="parent" location="top end" close-on-content-click>
|
||||||
<VList>
|
<VList>
|
||||||
<VListItem
|
<VListItem
|
||||||
v-for="(item, index) in dynamicButton?.menuItems"
|
v-for="(item, index) in visibleDynamicButtonMenuItems"
|
||||||
:key="item.titleKey || item.title || index"
|
:key="item.titleKey || item.title || index"
|
||||||
:base-color="item.color"
|
:base-color="item.color"
|
||||||
@click="item.action()"
|
@click="handleDynamicMenuItemClick(item)"
|
||||||
>
|
>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VIcon v-if="item.icon" :icon="item.icon" />
|
<VIcon v-if="item.icon" :icon="item.icon" />
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ import type { Component } from 'vue'
|
|||||||
import { getQueryValue } from '@/@core/utils'
|
import { getQueryValue } from '@/@core/utils'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useUserStore } from '@/stores'
|
||||||
|
import { buildUserPermissionContext, filterItemsByPermission, hasItemPermission, type PermissionProtectedItem } from '@/utils/permission'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||||
|
|
||||||
// 快捷工具只在弹窗打开时使用,按需加载避免默认布局首屏带上所有 system 视图。
|
// 快捷工具只在弹窗打开时使用,按需加载避免默认布局首屏带上所有 system 视图。
|
||||||
const NameTestView = defineAsyncComponent(() => import('@/views/system/NameTestView.vue'))
|
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 ShortcutMessageDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutMessageDialog.vue'))
|
||||||
const ShortcutToolDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutToolDialog.vue'))
|
const ShortcutToolDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutToolDialog.vue'))
|
||||||
|
|
||||||
type ShortcutItem = {
|
type ShortcutItem = PermissionProtectedItem & {
|
||||||
bodyClass?: string
|
bodyClass?: string
|
||||||
cardClass?: string
|
cardClass?: string
|
||||||
component?: Component
|
component?: Component
|
||||||
@@ -119,10 +123,14 @@ const shortcuts: ShortcutItem[] = [
|
|||||||
dialog: 'message',
|
dialog: 'message',
|
||||||
customDialog: ShortcutMessageDialog,
|
customDialog: ShortcutMessageDialog,
|
||||||
},
|
},
|
||||||
]
|
].map(item => ({ ...item, permission: 'admin' }))
|
||||||
|
|
||||||
|
const visibleShortcuts = computed(() => filterItemsByPermission(shortcuts, userPermissions.value))
|
||||||
|
|
||||||
/** 打开快捷工具对应的共享弹窗。 */
|
/** 打开快捷工具对应的共享弹窗。 */
|
||||||
function openShortcutDialog(item: (typeof shortcuts)[number]) {
|
function openShortcutDialog(item: (typeof shortcuts)[number]) {
|
||||||
|
if (!hasItemPermission(item, userPermissions.value)) return
|
||||||
|
|
||||||
appsMenu.value = false
|
appsMenu.value = false
|
||||||
|
|
||||||
if (item.customDialog) {
|
if (item.customDialog) {
|
||||||
@@ -150,7 +158,7 @@ function openShortcutDialog(item: (typeof shortcuts)[number]) {
|
|||||||
|
|
||||||
/** 供外部调用的打开消息弹窗方法。 */
|
/** 供外部调用的打开消息弹窗方法。 */
|
||||||
function openMessageDialogFromExternal() {
|
function openMessageDialogFromExternal() {
|
||||||
const messageShortcut = shortcuts.find(item => item.dialog === 'message')
|
const messageShortcut = visibleShortcuts.value.find(item => item.dialog === 'message')
|
||||||
if (messageShortcut) openShortcutDialog(messageShortcut)
|
if (messageShortcut) openShortcutDialog(messageShortcut)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +170,7 @@ defineExpose({
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const shortcut = getQueryValue('shortcut')
|
const shortcut = getQueryValue('shortcut')
|
||||||
if (shortcut) {
|
if (shortcut) {
|
||||||
const found = shortcuts.find(item => item.dialog === shortcut)
|
const found = visibleShortcuts.value.find(item => item.dialog === shortcut)
|
||||||
if (found) {
|
if (found) {
|
||||||
openShortcutDialog(found)
|
openShortcutDialog(found)
|
||||||
}
|
}
|
||||||
@@ -202,7 +210,7 @@ onMounted(() => {
|
|||||||
<div class="pa-3">
|
<div class="pa-3">
|
||||||
<div class="grid grid-cols-2 gap-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
|
<VCard
|
||||||
flat
|
flat
|
||||||
class="pa-2 d-flex align-center cursor-pointer transition-transform duration-300 hover:-translate-y-1 border h-full"
|
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,
|
THEME_CUSTOMIZER_CHANGE_EVENT,
|
||||||
type ThemeCustomizerSettings,
|
type ThemeCustomizerSettings,
|
||||||
} from '@/composables/useThemeCustomizer'
|
} from '@/composables/useThemeCustomizer'
|
||||||
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
|
|
||||||
const AboutDialog = defineAsyncComponent(() => import('@/components/dialog/AboutDialog.vue'))
|
const AboutDialog = defineAsyncComponent(() => import('@/components/dialog/AboutDialog.vue'))
|
||||||
const CustomCssDialog = defineAsyncComponent(() => import('@/components/dialog/CustomCssDialog.vue'))
|
const CustomCssDialog = defineAsyncComponent(() => import('@/components/dialog/CustomCssDialog.vue'))
|
||||||
@@ -104,7 +105,7 @@ function closeRestartProgress() {
|
|||||||
// 检测服务状态
|
// 检测服务状态
|
||||||
async function checkServiceStatus(): Promise<boolean> {
|
async function checkServiceStatus(): Promise<boolean> {
|
||||||
try {
|
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
|
return result?.success === true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return false
|
return false
|
||||||
@@ -163,6 +164,8 @@ async function pollServiceStatus() {
|
|||||||
|
|
||||||
// 执行重启操作
|
// 执行重启操作
|
||||||
async function restart() {
|
async function restart() {
|
||||||
|
if (!canAdmin.value) return
|
||||||
|
|
||||||
// 设置重启状态
|
// 设置重启状态
|
||||||
isRestarting.value = true
|
isRestarting.value = true
|
||||||
|
|
||||||
@@ -194,6 +197,8 @@ async function restart() {
|
|||||||
|
|
||||||
// 显示重启确认对话框
|
// 显示重启确认对话框
|
||||||
async function showRestartDialog() {
|
async function showRestartDialog() {
|
||||||
|
if (!canAdmin.value) return
|
||||||
|
|
||||||
const isConfirmed = await createConfirm({
|
const isConfirmed = await createConfirm({
|
||||||
type: 'warn',
|
type: 'warn',
|
||||||
title: t('app.confirmRestart'),
|
title: t('app.confirmRestart'),
|
||||||
@@ -207,6 +212,8 @@ async function showRestartDialog() {
|
|||||||
|
|
||||||
/** 显示站点认证共享弹窗。 */
|
/** 显示站点认证共享弹窗。 */
|
||||||
function showSiteAuthDialog() {
|
function showSiteAuthDialog() {
|
||||||
|
if (!canAdmin.value || userLevel.value >= 2) return
|
||||||
|
|
||||||
siteAuthDialogController?.close()
|
siteAuthDialogController?.close()
|
||||||
siteAuthDialogController = openSharedDialog(
|
siteAuthDialogController = openSharedDialog(
|
||||||
UserAuthDialog,
|
UserAuthDialog,
|
||||||
@@ -220,6 +227,8 @@ function showSiteAuthDialog() {
|
|||||||
|
|
||||||
/** 显示关于共享弹窗。 */
|
/** 显示关于共享弹窗。 */
|
||||||
function showAboutDialog() {
|
function showAboutDialog() {
|
||||||
|
if (!canAdmin.value) return
|
||||||
|
|
||||||
openSharedDialog(AboutDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
|
openSharedDialog(AboutDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,6 +241,8 @@ function siteAuthDone() {
|
|||||||
|
|
||||||
// 从用户 Store中获取信息
|
// 从用户 Store中获取信息
|
||||||
const superUser = computed(() => userStore.superUser)
|
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 userName = computed(() => userStore.userName)
|
||||||
const avatar = computed(() => userStore.avatar || avatar1)
|
const avatar = computed(() => userStore.avatar || avatar1)
|
||||||
const userLevel = computed(() => userStore.level)
|
const userLevel = computed(() => userStore.level)
|
||||||
@@ -384,6 +395,8 @@ function handleThemeCustomizerSettingsChange(event: Event) {
|
|||||||
|
|
||||||
// 获取自定义 CSS
|
// 获取自定义 CSS
|
||||||
async function getCustomCSS() {
|
async function getCustomCSS() {
|
||||||
|
if (!canAdmin.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('system/setting/UserCustomCSS')
|
const result: { [key: string]: any } = await api.get('system/setting/UserCustomCSS')
|
||||||
if (result && result.success && result.data?.value) {
|
if (result && result.success && result.data?.value) {
|
||||||
@@ -401,6 +414,8 @@ async function getCustomCSS() {
|
|||||||
|
|
||||||
/** 打开自定义 CSS 共享弹窗。 */
|
/** 打开自定义 CSS 共享弹窗。 */
|
||||||
function showCustomCssDialog() {
|
function showCustomCssDialog() {
|
||||||
|
if (!canAdmin.value) return
|
||||||
|
|
||||||
customCssDialogController?.close()
|
customCssDialogController?.close()
|
||||||
customCssDialogController = openSharedDialog(
|
customCssDialogController = openSharedDialog(
|
||||||
CustomCssDialog,
|
CustomCssDialog,
|
||||||
@@ -433,6 +448,8 @@ function showThemeCustomizerDrawer() {
|
|||||||
|
|
||||||
/** 保存自定义 CSS。 */
|
/** 保存自定义 CSS。 */
|
||||||
async function saveCustomCSS(css: string) {
|
async function saveCustomCSS(css: string) {
|
||||||
|
if (!canAdmin.value) return
|
||||||
|
|
||||||
customCSS.value = css
|
customCSS.value = css
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.post('system/setting/UserCustomCSS', css, {
|
const result: { [key: string]: any } = await api.post('system/setting/UserCustomCSS', css, {
|
||||||
@@ -512,7 +529,7 @@ const getThemeIcon = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
getCustomCSS()
|
if (canAdmin.value) getCustomCSS()
|
||||||
window.addEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerSettingsChange)
|
window.addEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerSettingsChange)
|
||||||
|
|
||||||
// 初始化透明度设置
|
// 初始化透明度设置
|
||||||
@@ -577,7 +594,7 @@ onUnmounted(() => {
|
|||||||
</VListItem>
|
</VListItem>
|
||||||
|
|
||||||
<VListItem
|
<VListItem
|
||||||
v-if="superUser"
|
v-if="canAdmin"
|
||||||
link
|
link
|
||||||
@click="isAdvancedMode ? router.push('/setting') : router.push('/setup-wizard')"
|
@click="isAdvancedMode ? router.push('/setting') : router.push('/setup-wizard')"
|
||||||
class="mb-1 rounded-lg"
|
class="mb-1 rounded-lg"
|
||||||
@@ -590,7 +607,7 @@ onUnmounted(() => {
|
|||||||
</VListItem>
|
</VListItem>
|
||||||
|
|
||||||
<!-- 👉 Site Auth -->
|
<!-- 👉 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>
|
<template #prepend>
|
||||||
<VIcon icon="mdi-lock-check-outline" />
|
<VIcon icon="mdi-lock-check-outline" />
|
||||||
</template>
|
</template>
|
||||||
@@ -674,7 +691,7 @@ onUnmounted(() => {
|
|||||||
</VListItem>
|
</VListItem>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<VListItem @click="showCustomCssDialog">
|
<VListItem v-if="canAdmin" @click="showCustomCssDialog">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VIcon icon="mdi-palette" />
|
<VIcon icon="mdi-palette" />
|
||||||
</template>
|
</template>
|
||||||
@@ -729,7 +746,7 @@ onUnmounted(() => {
|
|||||||
</VListItem>
|
</VListItem>
|
||||||
|
|
||||||
<!-- 👉 About -->
|
<!-- 👉 About -->
|
||||||
<VListItem @click="showAboutDialog" class="mb-1 rounded-lg" hover>
|
<VListItem v-if="canAdmin" @click="showAboutDialog" class="mb-1 rounded-lg" hover>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VIcon icon="mdi-information-outline" />
|
<VIcon icon="mdi-information-outline" />
|
||||||
</template>
|
</template>
|
||||||
@@ -737,10 +754,10 @@ onUnmounted(() => {
|
|||||||
</VListItem>
|
</VListItem>
|
||||||
|
|
||||||
<!-- Divider -->
|
<!-- Divider -->
|
||||||
<VDivider v-if="superUser" class="my-3" />
|
<VDivider v-if="canAdmin" class="my-3" />
|
||||||
|
|
||||||
<!-- 👉 restart -->
|
<!-- 👉 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>
|
<template #prepend>
|
||||||
<VIcon icon="mdi-restart" />
|
<VIcon icon="mdi-restart" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getNavMenus } from '@/router/i18n-menu'
|
|||||||
import { usePluginSidebarNavStore, useUserStore } from '@/stores'
|
import { usePluginSidebarNavStore, useUserStore } from '@/stores'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { filterPluginSidebarNavEntries } from '@/utils/pluginSidebarNav'
|
import { filterPluginSidebarNavEntries } from '@/utils/pluginSidebarNav'
|
||||||
import { filterMenusByPermission } from '@/utils/permission'
|
import { buildUserPermissionContext, filterMenusByPermission } from '@/utils/permission'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -13,10 +13,7 @@ const userStore = useUserStore()
|
|||||||
const pluginSidebarNavStore = usePluginSidebarNavStore()
|
const pluginSidebarNavStore = usePluginSidebarNavStore()
|
||||||
|
|
||||||
// 获取用户权限信息
|
// 获取用户权限信息
|
||||||
const userPermissions = computed(() => ({
|
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||||
is_superuser: userStore.superUser,
|
|
||||||
...userStore.permissions,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// 应用分组(以header分组)
|
// 应用分组(以header分组)
|
||||||
const appGroups = ref<Record<string, NavMenu[]>>({})
|
const appGroups = ref<Record<string, NavMenu[]>>({})
|
||||||
|
|||||||
+12
-9
@@ -5,13 +5,14 @@ import 'gridstack/dist/gridstack.min.css'
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||||
import type { DashboardItem } from '@/api/types'
|
import type { DashboardItem } from '@/api/types'
|
||||||
import { useUserStore } from '@/stores'
|
|
||||||
import DashboardElement from '@/components/misc/DashboardElement.vue'
|
import DashboardElement from '@/components/misc/DashboardElement.vue'
|
||||||
import { useDynamicButton, type DynamicButtonMenuItem } from '@/composables/useDynamicButton'
|
import { useDynamicButton, type DynamicButtonMenuItem } from '@/composables/useDynamicButton'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { usePWA } from '@/composables/usePWA'
|
import { usePWA } from '@/composables/usePWA'
|
||||||
import { getItemColor, initializeItemColors } from '@/utils/colorUtils'
|
import { getItemColor, initializeItemColors } from '@/utils/colorUtils'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
|
import { useUserStore } from '@/stores'
|
||||||
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
|
|
||||||
const ContentToggleSettingsDialog = defineAsyncComponent(
|
const ContentToggleSettingsDialog = defineAsyncComponent(
|
||||||
() => import('@/components/dialog/ContentToggleSettingsDialog.vue'),
|
() => import('@/components/dialog/ContentToggleSettingsDialog.vue'),
|
||||||
@@ -22,13 +23,14 @@ const { t } = useI18n()
|
|||||||
|
|
||||||
// PWA模式检测
|
// PWA模式检测
|
||||||
const { appMode } = usePWA()
|
const { appMode } = usePWA()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const canAdmin = computed(() =>
|
||||||
|
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
|
||||||
|
)
|
||||||
|
|
||||||
// 路由
|
// 路由
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
// 从用户 Store 中获取superuser信息
|
|
||||||
const superUser = useUserStore().superUser
|
|
||||||
|
|
||||||
const DASHBOARD_GRID_COLUMNS = 12
|
const DASHBOARD_GRID_COLUMNS = 12
|
||||||
const DASHBOARD_GRID_CELL_HEIGHT = 16
|
const DASHBOARD_GRID_CELL_HEIGHT = 16
|
||||||
const DASHBOARD_GRID_FALLBACK_ROWS = 4
|
const DASHBOARD_GRID_FALLBACK_ROWS = 4
|
||||||
@@ -461,6 +463,7 @@ const dashboardDynamicButtonMenuItems = computed<DynamicButtonMenuItem[] | undef
|
|||||||
title: isLayoutEditing.value ? t('dashboard.exitEditMode') : t('dashboard.editLayout'),
|
title: isLayoutEditing.value ? t('dashboard.exitEditMode') : t('dashboard.editLayout'),
|
||||||
icon: isLayoutEditing.value ? 'mdi-check' : 'mdi-view-dashboard-edit',
|
icon: isLayoutEditing.value ? 'mdi-check' : 'mdi-view-dashboard-edit',
|
||||||
color: 'primary',
|
color: 'primary',
|
||||||
|
permission: 'admin',
|
||||||
action: toggleDashboardLayoutEditing,
|
action: toggleDashboardLayoutEditing,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -470,6 +473,7 @@ const dashboardDynamicButtonMenuItems = computed<DynamicButtonMenuItem[] | undef
|
|||||||
title: t('dashboard.resetLayout'),
|
title: t('dashboard.resetLayout'),
|
||||||
icon: 'mdi-restore',
|
icon: 'mdi-restore',
|
||||||
color: 'warning',
|
color: 'warning',
|
||||||
|
permission: 'admin',
|
||||||
action: resetDashboardGridLayout,
|
action: resetDashboardGridLayout,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -478,6 +482,7 @@ const dashboardDynamicButtonMenuItems = computed<DynamicButtonMenuItem[] | undef
|
|||||||
title: t('dashboard.settings'),
|
title: t('dashboard.settings'),
|
||||||
icon: 'mdi-tune',
|
icon: 'mdi-tune',
|
||||||
color: 'info',
|
color: 'info',
|
||||||
|
permission: 'admin',
|
||||||
action: openDashboardSettings,
|
action: openDashboardSettings,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -487,6 +492,7 @@ const dashboardDynamicButtonMenuItems = computed<DynamicButtonMenuItem[] | undef
|
|||||||
useDynamicButton({
|
useDynamicButton({
|
||||||
icon: 'mdi-view-dashboard-edit',
|
icon: 'mdi-view-dashboard-edit',
|
||||||
menuItems: dashboardDynamicButtonMenuItems,
|
menuItems: dashboardDynamicButtonMenuItems,
|
||||||
|
permission: 'admin',
|
||||||
show: computed(() => appMode.value && route.path === '/dashboard'),
|
show: computed(() => appMode.value && route.path === '/dashboard'),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -583,9 +589,6 @@ function buildPluginDashboardId(plugin_id: string, key: string) {
|
|||||||
|
|
||||||
// 调用API获取所有插件的仪表板元信息
|
// 调用API获取所有插件的仪表板元信息
|
||||||
async function getPluginDashboardMeta() {
|
async function getPluginDashboardMeta() {
|
||||||
// 只有超级用户才能获取
|
|
||||||
if (!superUser) return
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
pluginDashboardMeta.value = (await api.get('/plugin/dashboard/meta')) ?? []
|
pluginDashboardMeta.value = (await api.get('/plugin/dashboard/meta')) ?? []
|
||||||
if (!isNullOrEmptyObject(pluginDashboardMeta.value)) {
|
if (!isNullOrEmptyObject(pluginDashboardMeta.value)) {
|
||||||
@@ -628,7 +631,7 @@ function schedulePluginDashboardRefresh(item: DashboardItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function refreshEnabledPluginDashboards() {
|
function refreshEnabledPluginDashboards() {
|
||||||
if (!superUser || isNullOrEmptyObject(pluginDashboardMeta.value)) return
|
if (isNullOrEmptyObject(pluginDashboardMeta.value)) return
|
||||||
|
|
||||||
pluginDashboardMeta.value.forEach((pluginDashboard: { id: string; key: string }) => {
|
pluginDashboardMeta.value.forEach((pluginDashboard: { id: string; key: string }) => {
|
||||||
const pluginDashboardId = buildPluginDashboardId(pluginDashboard.id, pluginDashboard.key)
|
const pluginDashboardId = buildPluginDashboardId(pluginDashboard.id, pluginDashboard.key)
|
||||||
@@ -1048,7 +1051,7 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Teleport to="body" v-if="!appMode && route.path === '/dashboard'">
|
<Teleport to="body" v-if="!appMode && route.path === '/dashboard'">
|
||||||
<div class="compact-fab-stack">
|
<div v-if="canAdmin" class="compact-fab-stack">
|
||||||
<VFab
|
<VFab
|
||||||
icon="mdi-tune"
|
icon="mdi-tune"
|
||||||
color="info"
|
color="info"
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ registerHeaderTab({
|
|||||||
variant: 'text',
|
variant: 'text',
|
||||||
color: 'grey',
|
color: 'grey',
|
||||||
class: 'settings-icon-button',
|
class: 'settings-icon-button',
|
||||||
|
permission: 'discovery',
|
||||||
action: openOrderConfigDialog,
|
action: openOrderConfigDialog,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
+2
-5
@@ -12,7 +12,7 @@ import { SUPPORTED_LOCALES, SupportedLocale } from '@/types/i18n'
|
|||||||
import { getCurrentLocale, setI18nLanguage } from '@/plugins/i18n'
|
import { getCurrentLocale, setI18nLanguage } from '@/plugins/i18n'
|
||||||
import { useTheme } from 'vuetify'
|
import { useTheme } from 'vuetify'
|
||||||
import { getNavMenus } from '@/router/i18n-menu'
|
import { getNavMenus } from '@/router/i18n-menu'
|
||||||
import { filterMenusByPermission } from '@/utils/permission'
|
import { buildUserPermissionContext, filterMenusByPermission } from '@/utils/permission'
|
||||||
import type { ApiResponse } from '@/api/types'
|
import type { ApiResponse } from '@/api/types'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
import { loadRemoteComponentFromModule, type RemoteModule } from '@/utils/federationLoader'
|
import { loadRemoteComponentFromModule, type RemoteModule } from '@/utils/federationLoader'
|
||||||
@@ -539,10 +539,7 @@ async function handleLoginSuccess(response: any) {
|
|||||||
wizard: response.wizard,
|
wizard: response.wizard,
|
||||||
}
|
}
|
||||||
|
|
||||||
const userPermissions = {
|
const userPermissions = buildUserPermissionContext(userPayload.superUser, userPayload.permissions)
|
||||||
is_superuser: userPayload.superUser,
|
|
||||||
...userPayload.permissions,
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredMenus = filterMenusByPermission(navMenus.value, userPermissions)
|
const filteredMenus = filterMenusByPermission(navMenus.value, userPermissions)
|
||||||
if (filteredMenus.length === 0) {
|
if (filteredMenus.length === 0) {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { usePWA } from '@/composables/usePWA'
|
|||||||
import { getItemColor, initializeItemColors } from '@/utils/colorUtils'
|
import { getItemColor, initializeItemColors } from '@/utils/colorUtils'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
import { getRecommendTabs } from '@/router/i18n-menu'
|
import { getRecommendTabs } from '@/router/i18n-menu'
|
||||||
|
import { useUserStore } from '@/stores'
|
||||||
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
|
|
||||||
const ContentToggleSettingsDialog = defineAsyncComponent(() => import('@/components/dialog/ContentToggleSettingsDialog.vue'))
|
const ContentToggleSettingsDialog = defineAsyncComponent(() => import('@/components/dialog/ContentToggleSettingsDialog.vue'))
|
||||||
|
|
||||||
@@ -16,9 +18,13 @@ const { appMode } = usePWA()
|
|||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
// 路由
|
// 路由
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const canDiscovery = computed(() =>
|
||||||
|
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'discovery'),
|
||||||
|
)
|
||||||
|
|
||||||
// 当前选择的分类
|
// 当前选择的分类
|
||||||
const currentCategory = ref(t('recommend.all'))
|
const currentCategory = ref(t('recommend.all'))
|
||||||
@@ -235,6 +241,7 @@ registerHeaderTab({
|
|||||||
useDynamicButton({
|
useDynamicButton({
|
||||||
icon: 'mdi-tune',
|
icon: 'mdi-tune',
|
||||||
onClick: openRecommendSettings,
|
onClick: openRecommendSettings,
|
||||||
|
permission: 'discovery',
|
||||||
show: computed(() => appMode.value),
|
show: computed(() => appMode.value),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -298,7 +305,7 @@ onActivated(async () => {
|
|||||||
|
|
||||||
<!-- 快速滚动到顶部按钮 -->
|
<!-- 快速滚动到顶部按钮 -->
|
||||||
<Teleport to="body" v-if="route.path === '/recommend'">
|
<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
|
<VFab
|
||||||
icon="mdi-tune"
|
icon="mdi-tune"
|
||||||
color="primary"
|
color="primary"
|
||||||
|
|||||||
@@ -17,11 +17,17 @@ import { useDynamicButton } from '@/composables/useDynamicButton'
|
|||||||
import { usePWA } from '@/composables/usePWA'
|
import { usePWA } from '@/composables/usePWA'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
|
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
|
||||||
|
import { useUserStore } from '@/stores'
|
||||||
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const { appMode } = usePWA()
|
const { appMode } = usePWA()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const canSearch = computed(() =>
|
||||||
|
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'search'),
|
||||||
|
)
|
||||||
|
|
||||||
// 提示框
|
// 提示框
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -257,6 +263,7 @@ function toggleViewType() {
|
|||||||
useDynamicButton({
|
useDynamicButton({
|
||||||
icon: viewToggleIcon,
|
icon: viewToggleIcon,
|
||||||
onClick: toggleViewType,
|
onClick: toggleViewType,
|
||||||
|
permission: 'search',
|
||||||
show: computed(() => appMode.value && isRefreshed.value),
|
show: computed(() => appMode.value && isRefreshed.value),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1579,7 +1586,7 @@ onUnmounted(() => {
|
|||||||
<LoadingBanner v-else-if="!isRefreshed && !isSearchLoading" />
|
<LoadingBanner v-else-if="!isRefreshed && !isSearchLoading" />
|
||||||
|
|
||||||
<Teleport to="body" v-if="route.path === '/resource'">
|
<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
|
<VFab
|
||||||
:icon="viewToggleIcon"
|
:icon="viewToggleIcon"
|
||||||
color="primary"
|
color="primary"
|
||||||
|
|||||||
+15
-3
@@ -7,6 +7,7 @@ import { useDynamicButton } from '@/composables/useDynamicButton'
|
|||||||
import { usePWA } from '@/composables/usePWA'
|
import { usePWA } from '@/composables/usePWA'
|
||||||
import { useUserStore } from '@/stores'
|
import { useUserStore } from '@/stores'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
|
|
||||||
import { getSubscribeMovieTabs, getSubscribeTvTabs } from '@/router/i18n-menu'
|
import { getSubscribeMovieTabs, getSubscribeTvTabs } from '@/router/i18n-menu'
|
||||||
|
|
||||||
@@ -193,9 +194,12 @@ function selectSubscribeSort(value: SubscribeSortBy) {
|
|||||||
const filterActivator = computed(() => '[data-menu-activator="filter-btn"]')
|
const filterActivator = computed(() => '[data-menu-activator="filter-btn"]')
|
||||||
const searchActivator = computed(() => '[data-menu-activator="share-filter-btn"]')
|
const searchActivator = computed(() => '[data-menu-activator="share-filter-btn"]')
|
||||||
|
|
||||||
const showDefaultRuleAction = computed(() => activeTab.value === 'mysub')
|
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||||
const showSubscribeHistoryAction = computed(() => showDefaultRuleAction.value && userStore.superUser)
|
const canAdmin = computed(() => hasPermission(userPermissions.value, 'admin'))
|
||||||
const showShareStatisticsAction = computed(() => activeTab.value === 'share')
|
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() {
|
function openDefaultRuleDialog() {
|
||||||
openSharedDialog(
|
openSharedDialog(
|
||||||
@@ -255,6 +259,7 @@ const subscribeDynamicMenuItems = computed(() => {
|
|||||||
titleKey: string
|
titleKey: string
|
||||||
titleParams?: Record<string, unknown>
|
titleParams?: Record<string, unknown>
|
||||||
icon: string
|
icon: string
|
||||||
|
permission: 'admin'
|
||||||
action: () => void
|
action: () => void
|
||||||
}> = []
|
}> = []
|
||||||
|
|
||||||
@@ -263,6 +268,7 @@ const subscribeDynamicMenuItems = computed(() => {
|
|||||||
titleKey: 'dialog.subscribeHistory.title',
|
titleKey: 'dialog.subscribeHistory.title',
|
||||||
titleParams: { type: subType },
|
titleParams: { type: subType },
|
||||||
icon: 'mdi-history',
|
icon: 'mdi-history',
|
||||||
|
permission: 'admin',
|
||||||
action: openSubscribeHistoryDialog,
|
action: openSubscribeHistoryDialog,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -270,6 +276,7 @@ const subscribeDynamicMenuItems = computed(() => {
|
|||||||
items.push({
|
items.push({
|
||||||
titleKey: 'dialog.subscribeEdit.titleDefault',
|
titleKey: 'dialog.subscribeEdit.titleDefault',
|
||||||
icon: 'mdi-clipboard-edit-outline',
|
icon: 'mdi-clipboard-edit-outline',
|
||||||
|
permission: 'admin',
|
||||||
action: openDefaultRuleDialog,
|
action: openDefaultRuleDialog,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -305,6 +312,7 @@ useDynamicButton({
|
|||||||
icon: subscribeDynamicIcon,
|
icon: subscribeDynamicIcon,
|
||||||
onClick: handleSubscribeDynamicAction,
|
onClick: handleSubscribeDynamicAction,
|
||||||
menuItems: subscribeDynamicMenuItems,
|
menuItems: subscribeDynamicMenuItems,
|
||||||
|
permission: 'subscribe',
|
||||||
show: computed(() => appMode.value && (showDefaultRuleAction.value || showShareStatisticsAction.value)),
|
show: computed(() => appMode.value && (showDefaultRuleAction.value || showShareStatisticsAction.value)),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -322,6 +330,7 @@ registerHeaderTab({
|
|||||||
color: filterButtonColor,
|
color: filterButtonColor,
|
||||||
class: 'settings-icon-button',
|
class: 'settings-icon-button',
|
||||||
dataAttr: 'filter-btn',
|
dataAttr: 'filter-btn',
|
||||||
|
permission: 'subscribe',
|
||||||
action: () => {
|
action: () => {
|
||||||
filterSubscribeDialog.value = true
|
filterSubscribeDialog.value = true
|
||||||
},
|
},
|
||||||
@@ -332,6 +341,7 @@ registerHeaderTab({
|
|||||||
variant: 'text',
|
variant: 'text',
|
||||||
color: computed(() => (subscribeSortMode.value ? 'warning' : 'gray')),
|
color: computed(() => (subscribeSortMode.value ? 'warning' : 'gray')),
|
||||||
class: 'settings-icon-button',
|
class: 'settings-icon-button',
|
||||||
|
permission: 'subscribe',
|
||||||
action: toggleSubscribeSortMode,
|
action: toggleSubscribeSortMode,
|
||||||
show: computed(() => activeTab.value === 'mysub'),
|
show: computed(() => activeTab.value === 'mysub'),
|
||||||
},
|
},
|
||||||
@@ -340,6 +350,7 @@ registerHeaderTab({
|
|||||||
variant: 'text',
|
variant: 'text',
|
||||||
color: 'gray',
|
color: 'gray',
|
||||||
class: 'settings-icon-button',
|
class: 'settings-icon-button',
|
||||||
|
permission: 'subscribe',
|
||||||
action: () => {
|
action: () => {
|
||||||
// 触发批量管理模式
|
// 触发批量管理模式
|
||||||
const event = new CustomEvent('toggle-batch-mode')
|
const event = new CustomEvent('toggle-batch-mode')
|
||||||
@@ -353,6 +364,7 @@ registerHeaderTab({
|
|||||||
color: computed(() => (shareKeywordInput.value ? 'primary' : 'gray')),
|
color: computed(() => (shareKeywordInput.value ? 'primary' : 'gray')),
|
||||||
class: 'settings-icon-button',
|
class: 'settings-icon-button',
|
||||||
dataAttr: 'share-filter-btn',
|
dataAttr: 'share-filter-btn',
|
||||||
|
permission: 'subscribe',
|
||||||
action: () => {
|
action: () => {
|
||||||
searchShareDialog.value = true
|
searchShareDialog.value = true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,12 +7,18 @@ import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
|||||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||||
import { usePWA } from '@/composables/usePWA'
|
import { usePWA } from '@/composables/usePWA'
|
||||||
import { getWorkflowTabs } from '@/router/i18n-menu'
|
import { getWorkflowTabs } from '@/router/i18n-menu'
|
||||||
|
import { useUserStore } from '@/stores'
|
||||||
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { appMode } = usePWA()
|
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 activeTab = ref((route.query.tab as string) || 'list')
|
||||||
const workflowListViewRef = ref<InstanceType<typeof WorkflowListView> | null>(null)
|
const workflowListViewRef = ref<InstanceType<typeof WorkflowListView> | null>(null)
|
||||||
@@ -61,6 +67,7 @@ onUnmounted(() => {
|
|||||||
useDynamicButton({
|
useDynamicButton({
|
||||||
icon: 'mdi-plus',
|
icon: 'mdi-plus',
|
||||||
onClick: openAddWorkflowDialog,
|
onClick: openAddWorkflowDialog,
|
||||||
|
permission: 'manage',
|
||||||
show: computed(() => appMode.value && activeTab.value === 'list'),
|
show: computed(() => appMode.value && activeTab.value === 'list'),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -78,6 +85,7 @@ registerHeaderTab({
|
|||||||
color: computed(() => (shareKeywordInput.value ? 'primary' : 'gray')),
|
color: computed(() => (shareKeywordInput.value ? 'primary' : 'gray')),
|
||||||
class: 'settings-icon-button',
|
class: 'settings-icon-button',
|
||||||
dataAttr: 'share-filter-btn',
|
dataAttr: 'share-filter-btn',
|
||||||
|
permission: 'manage',
|
||||||
show: computed(() => activeTab.value === 'share'),
|
show: computed(() => activeTab.value === 'share'),
|
||||||
action: () => {
|
action: () => {
|
||||||
searchShareDialog.value = true
|
searchShareDialog.value = true
|
||||||
@@ -138,7 +146,7 @@ onMounted(() => {
|
|||||||
</VMenu>
|
</VMenu>
|
||||||
</Teleport>
|
</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">
|
<div class="compact-fab-stack">
|
||||||
<VFab
|
<VFab
|
||||||
icon="mdi-plus"
|
icon="mdi-plus"
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useGlobalSettingsStore } from '@/stores'
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
import type { NavMenuTabItem } from '@/@layouts/types'
|
import type { NavMenu, NavMenuTabItem } from '@/@layouts/types'
|
||||||
import type { Composer } from 'vue-i18n'
|
import type { Composer } from 'vue-i18n'
|
||||||
|
|
||||||
// 构建路由菜单,每次调用时使用当前的语言环境
|
// 构建路由菜单,每次调用时使用当前的语言环境
|
||||||
export function getNavMenus(t: Composer['t']) {
|
export function getNavMenus(t: Composer['t']): NavMenu[] {
|
||||||
const globalSettingsStore = useGlobalSettingsStore()
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
|
||||||
// 检查是否为高级模式
|
// 检查是否为高级模式
|
||||||
@@ -17,7 +17,7 @@ export function getNavMenus(t: Composer['t']) {
|
|||||||
header: t('menu.start'),
|
header: t('menu.start'),
|
||||||
admin: false,
|
admin: false,
|
||||||
footer: true,
|
footer: true,
|
||||||
permission: 'manage',
|
permission: 'admin',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('navItems.searchResult'),
|
title: t('navItems.searchResult'),
|
||||||
@@ -119,7 +119,7 @@ export function getNavMenus(t: Composer['t']) {
|
|||||||
to: '/plugins',
|
to: '/plugins',
|
||||||
header: t('menu.system'),
|
header: t('menu.system'),
|
||||||
admin: true,
|
admin: true,
|
||||||
permission: 'manage',
|
permission: 'admin',
|
||||||
tabs: getPluginTabs(t),
|
tabs: getPluginTabs(t),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -148,7 +148,7 @@ export function getNavMenus(t: Composer['t']) {
|
|||||||
admin: true,
|
admin: true,
|
||||||
permission: 'admin',
|
permission: 'admin',
|
||||||
tabs: getSettingTabs(t),
|
tabs: getSettingTabs(t),
|
||||||
},
|
} as NavMenu,
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
]
|
]
|
||||||
|
|||||||
+66
-2
@@ -1,7 +1,8 @@
|
|||||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
import { configureNProgress } from '@/api/nprogress'
|
import { configureNProgress } from '@/api/nprogress'
|
||||||
import { useAuthStore } from '@/stores'
|
import { useAuthStore, usePluginSidebarNavStore, useUserStore } from '@/stores'
|
||||||
import { setNavigatingState as setRequestNavigatingState } from '@/utils/requestOptimizer'
|
import { setNavigatingState as setRequestNavigatingState } from '@/utils/requestOptimizer'
|
||||||
|
import { buildUserPermissionContext, hasPermission, type UserPermissionKey } from '@/utils/permission'
|
||||||
|
|
||||||
// Nprogress
|
// Nprogress
|
||||||
configureNProgress()
|
configureNProgress()
|
||||||
@@ -15,7 +16,15 @@ const router = createRouter({
|
|||||||
return { top: 0 }
|
return { top: 0 }
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/', redirect: '/dashboard' },
|
{
|
||||||
|
path: '/',
|
||||||
|
redirect: () => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
if (!authStore.token) return '/login'
|
||||||
|
return userStore.superUser ? '/dashboard' : '/apps'
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
component: () => import('../layouts/default.vue'),
|
component: () => import('../layouts/default.vue'),
|
||||||
@@ -26,6 +35,7 @@ const router = createRouter({
|
|||||||
meta: {
|
meta: {
|
||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
|
permission: 'admin',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -34,6 +44,7 @@ const router = createRouter({
|
|||||||
meta: {
|
meta: {
|
||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
|
permission: 'discovery',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -42,6 +53,7 @@ const router = createRouter({
|
|||||||
meta: {
|
meta: {
|
||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
|
permission: 'discovery',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -50,6 +62,7 @@ const router = createRouter({
|
|||||||
meta: {
|
meta: {
|
||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
|
permission: 'search',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -59,6 +72,7 @@ const router = createRouter({
|
|||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
keepAliveKey: 'subscribe-movie',
|
keepAliveKey: 'subscribe-movie',
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
|
permission: 'subscribe',
|
||||||
subType: '电影',
|
subType: '电影',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -69,6 +83,7 @@ const router = createRouter({
|
|||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
keepAliveKey: 'subscribe-tv',
|
keepAliveKey: 'subscribe-tv',
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
|
permission: 'subscribe',
|
||||||
subType: '电视剧',
|
subType: '电视剧',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -77,6 +92,7 @@ const router = createRouter({
|
|||||||
component: () => import('../pages/subscribe-share.vue'),
|
component: () => import('../pages/subscribe-share.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
|
permission: 'subscribe',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -85,6 +101,7 @@ const router = createRouter({
|
|||||||
meta: {
|
meta: {
|
||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
|
permission: 'manage',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -93,6 +110,7 @@ const router = createRouter({
|
|||||||
meta: {
|
meta: {
|
||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
|
permission: 'subscribe',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -101,6 +119,7 @@ const router = createRouter({
|
|||||||
meta: {
|
meta: {
|
||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
|
permission: 'manage',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -109,6 +128,7 @@ const router = createRouter({
|
|||||||
meta: {
|
meta: {
|
||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
|
permission: 'manage',
|
||||||
hideFooter: true,
|
hideFooter: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -118,6 +138,7 @@ const router = createRouter({
|
|||||||
meta: {
|
meta: {
|
||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
|
permission: 'manage',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -126,6 +147,7 @@ const router = createRouter({
|
|||||||
meta: {
|
meta: {
|
||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
|
permission: 'admin',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -142,6 +164,7 @@ const router = createRouter({
|
|||||||
meta: {
|
meta: {
|
||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
|
permission: 'admin',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -158,6 +181,7 @@ const router = createRouter({
|
|||||||
meta: {
|
meta: {
|
||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
|
permission: 'admin',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -166,6 +190,7 @@ const router = createRouter({
|
|||||||
props: true,
|
props: true,
|
||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
|
permission: 'discovery',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -189,6 +214,7 @@ const router = createRouter({
|
|||||||
component: () => import('../pages/media.vue'),
|
component: () => import('../pages/media.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
|
permission: 'discovery',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -197,6 +223,7 @@ const router = createRouter({
|
|||||||
meta: {
|
meta: {
|
||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
|
permission: 'manage',
|
||||||
hideFooter: true,
|
hideFooter: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -223,6 +250,7 @@ const router = createRouter({
|
|||||||
component: () => import('../pages/setup.vue'),
|
component: () => import('../pages/setup.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
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) => {
|
router.beforeEach(async (to: any, from: any, next: any) => {
|
||||||
// 设置导航状态 - 同时中断API请求
|
// 设置导航状态 - 同时中断API请求
|
||||||
@@ -249,6 +295,24 @@ router.beforeEach(async (to: any, from: any, next: any) => {
|
|||||||
// 用户未登录,重定向到登录页
|
// 用户未登录,重定向到登录页
|
||||||
setRequestNavigatingState(false)
|
setRequestNavigatingState(false)
|
||||||
next('/login')
|
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 {
|
} else {
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-17
@@ -4,6 +4,20 @@ export interface UserPermissions {
|
|||||||
search: boolean // 搜索权限
|
search: boolean // 搜索权限
|
||||||
subscribe: boolean // 订阅权限
|
subscribe: boolean // 订阅权限
|
||||||
manage: 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,
|
search: true,
|
||||||
subscribe: true,
|
subscribe: true,
|
||||||
manage: false,
|
manage: false,
|
||||||
|
admin: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 管理员权限配置
|
// 管理员权限配置
|
||||||
@@ -20,44 +35,51 @@ export const ADMIN_PERMISSIONS: UserPermissions = {
|
|||||||
search: true,
|
search: true,
|
||||||
subscribe: true,
|
subscribe: true,
|
||||||
manage: 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) {
|
if (userPermissions?.is_superuser === true) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// admin 入口只允许超级管理员,不从普通用户 permissions 字段放行
|
||||||
|
if (permission === 'admin') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// 检查具体权限
|
// 检查具体权限
|
||||||
const permissions = userPermissions || {}
|
const permissions = userPermissions || {}
|
||||||
return permissions[permission] === true
|
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))
|
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))
|
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[] {
|
export function filterMenusByPermission(menus: any[], userPermissions: any): any[] {
|
||||||
return menus.filter(menu => {
|
return filterItemsByPermission(menus, userPermissions)
|
||||||
// 如果是超级用户,拥有所有权限
|
|
||||||
if (userPermissions?.is_superuser) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果菜单没有权限要求,默认显示
|
|
||||||
if (!menu.permission) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查用户是否拥有所需权限
|
|
||||||
return hasPermission(userPermissions, menu.permission)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { useTheme } from 'vuetify'
|
import { useTheme } from 'vuetify'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { hexToRgb } from '@layouts/utils'
|
import { hexToRgb } from '@layouts/utils'
|
||||||
import { useUserStore } from '@/stores'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
@@ -10,10 +9,6 @@ const { t } = useI18n()
|
|||||||
|
|
||||||
const vuetifyTheme = useTheme()
|
const vuetifyTheme = useTheme()
|
||||||
|
|
||||||
// 用户 Store
|
|
||||||
const userStore = useUserStore()
|
|
||||||
const superUser = userStore.superUser
|
|
||||||
|
|
||||||
const options = controlledComputed(
|
const options = controlledComputed(
|
||||||
() => vuetifyTheme.name.value,
|
() => vuetifyTheme.name.value,
|
||||||
() => {
|
() => {
|
||||||
@@ -147,7 +142,7 @@ onActivated(() => {
|
|||||||
<p>{{ t('dashboard.weeklyOverviewDescription', { count: totalCount }) }} 😎</p>
|
<p>{{ t('dashboard.weeklyOverviewDescription', { count: totalCount }) }} 😎</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<VBtn v-if="superUser" block to="/history"> {{ t('common.viewDetails') }} </VBtn>
|
<VBtn block to="/history"> {{ t('common.viewDetails') }} </VBtn>
|
||||||
</div>
|
</div>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
</VCard>
|
</VCard>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { isNullOrEmptyObject } from '@/@core/utils'
|
|||||||
import { useUserStore } from '@/stores'
|
import { useUserStore } from '@/stores'
|
||||||
import { useTheme } from 'vuetify'
|
import { useTheme } from 'vuetify'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { hasPermission } from '@/utils/permission'
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
import { useGlobalSettingsStore } from '@/stores'
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
import { openMediaServerItem, openDoubanApp } from '@/utils/appDeepLink'
|
import { openMediaServerItem, openDoubanApp } from '@/utils/appDeepLink'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
@@ -39,10 +39,9 @@ const globalSettings = globalSettingsStore.globalSettings
|
|||||||
|
|
||||||
// 用户 Store
|
// 用户 Store
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||||
const canSearch = computed(() =>
|
const canSearch = computed(() => hasPermission(userPermissions.value, 'search'))
|
||||||
hasPermission({ is_superuser: userStore.superUser, ...userStore.permissions }, 'search'),
|
const canSubscribe = computed(() => hasPermission(userPermissions.value, 'subscribe'))
|
||||||
)
|
|
||||||
|
|
||||||
// 提示框
|
// 提示框
|
||||||
const $toast = useToast()
|
const $toast = useToast()
|
||||||
@@ -130,7 +129,7 @@ async function querySites() {
|
|||||||
// 查询用户选中的站点
|
// 查询用户选中的站点
|
||||||
async function querySelectedSites() {
|
async function querySelectedSites() {
|
||||||
try {
|
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 ?? []
|
selectedSites.value = result.data?.value ?? []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -543,12 +542,11 @@ async function handlePlay() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function queryDefaultSubscribeConfig() {
|
async function queryDefaultSubscribeConfig() {
|
||||||
// 非管理员不显示
|
if (!canSubscribe.value) return false
|
||||||
if (!userStore.superUser) return false
|
|
||||||
try {
|
try {
|
||||||
let subscribe_config_url = ''
|
let subscribe_config_url = ''
|
||||||
if (mediaProps.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
|
if (mediaProps.type === '电影') subscribe_config_url = 'system/setting/public/DefaultMovieSubscribeConfig'
|
||||||
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
|
else subscribe_config_url = 'system/setting/public/DefaultTvSubscribeConfig'
|
||||||
|
|
||||||
const result: { [key: string]: any } = await api.get(subscribe_config_url)
|
const result: { [key: string]: any } = await api.get(subscribe_config_url)
|
||||||
|
|
||||||
@@ -697,7 +695,7 @@ onBeforeMount(() => {
|
|||||||
{{ t('media.actions.searchSubtitle') }}
|
{{ t('media.actions.searchSubtitle') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<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"
|
class="ms-2 mb-2"
|
||||||
:color="getSubscribeColor"
|
:color="getSubscribeColor"
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
@@ -812,6 +810,7 @@ onBeforeMount(() => {
|
|||||||
{{ getExistText(season.season_number || 0) }}
|
{{ getExistText(season.season_number || 0) }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<IconBtn
|
<IconBtn
|
||||||
|
v-if="canSubscribe"
|
||||||
class="ms-1"
|
class="ms-1"
|
||||||
:color="seasonsSubscribed[season.season_number || 0] ? 'error' : 'warning'"
|
:color="seasonsSubscribed[season.season_number || 0] ? 'error' : 'warning'"
|
||||||
variant="text"
|
variant="text"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import NoDataFound from '@/components/NoDataFound.vue'
|
|||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||||
import { getPluginTabs } from '@/router/i18n-menu'
|
import { getPluginTabs } from '@/router/i18n-menu'
|
||||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
import { useDynamicButton, type DynamicButtonMenuItem } from '@/composables/useDynamicButton'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import PluginMixedSortCard from '@/components/cards/PluginMixedSortCard.vue'
|
import PluginMixedSortCard from '@/components/cards/PluginMixedSortCard.vue'
|
||||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||||
@@ -14,11 +14,14 @@ import { usePWA } from '@/composables/usePWA'
|
|||||||
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||||||
import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh'
|
import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
|
import { useUserStore } from '@/stores'
|
||||||
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
// 市场卡片、拖拽排序和市场设置只在对应标签/操作中需要,延迟到真正使用时加载。
|
// 市场卡片、拖拽排序和市场设置只在对应标签/操作中需要,延迟到真正使用时加载。
|
||||||
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
|
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
|
||||||
@@ -63,6 +66,7 @@ registerHeaderTab({
|
|||||||
),
|
),
|
||||||
class: 'settings-icon-button',
|
class: 'settings-icon-button',
|
||||||
dataAttr: 'installed-filter-btn',
|
dataAttr: 'installed-filter-btn',
|
||||||
|
permission: 'admin',
|
||||||
action: () => {
|
action: () => {
|
||||||
filterInstalledPluginDialog.value = true
|
filterInstalledPluginDialog.value = true
|
||||||
},
|
},
|
||||||
@@ -73,6 +77,7 @@ registerHeaderTab({
|
|||||||
variant: 'text',
|
variant: 'text',
|
||||||
color: computed(() => (sortMode.value ? 'warning' : 'gray')),
|
color: computed(() => (sortMode.value ? 'warning' : 'gray')),
|
||||||
class: 'settings-icon-button',
|
class: 'settings-icon-button',
|
||||||
|
permission: 'admin',
|
||||||
action: () => {
|
action: () => {
|
||||||
sortMode.value = !sortMode.value
|
sortMode.value = !sortMode.value
|
||||||
},
|
},
|
||||||
@@ -84,6 +89,7 @@ registerHeaderTab({
|
|||||||
color: computed(() => (isFilterFormEmpty.value ? 'gray' : 'primary')),
|
color: computed(() => (isFilterFormEmpty.value ? 'gray' : 'primary')),
|
||||||
class: 'settings-icon-button',
|
class: 'settings-icon-button',
|
||||||
dataAttr: 'market-filter-btn',
|
dataAttr: 'market-filter-btn',
|
||||||
|
permission: 'admin',
|
||||||
action: () => {
|
action: () => {
|
||||||
filterMarketPluginDialog.value = true
|
filterMarketPluginDialog.value = true
|
||||||
},
|
},
|
||||||
@@ -95,6 +101,7 @@ registerHeaderTab({
|
|||||||
color: 'gray',
|
color: 'gray',
|
||||||
class: 'settings-icon-button',
|
class: 'settings-icon-button',
|
||||||
loading: computed(() => isMarketRefreshing.value),
|
loading: computed(() => isMarketRefreshing.value),
|
||||||
|
permission: 'admin',
|
||||||
action: () => {
|
action: () => {
|
||||||
refreshMarket()
|
refreshMarket()
|
||||||
},
|
},
|
||||||
@@ -105,6 +112,7 @@ registerHeaderTab({
|
|||||||
variant: 'text',
|
variant: 'text',
|
||||||
color: 'gray',
|
color: 'gray',
|
||||||
class: 'settings-icon-button',
|
class: 'settings-icon-button',
|
||||||
|
permission: 'admin',
|
||||||
action: () => {
|
action: () => {
|
||||||
backToMain()
|
backToMain()
|
||||||
},
|
},
|
||||||
@@ -1058,17 +1066,23 @@ function openMarketSettingDialog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const showSearchAction = computed(() => activeTab.value === 'installed' || activeTab.value === 'market')
|
const showSearchAction = computed(() => activeTab.value === 'installed' || activeTab.value === 'market')
|
||||||
const showNewFolderAction = computed(() => activeTab.value === 'installed' && !currentFolder.value)
|
const canAdmin = computed(() =>
|
||||||
const showMarketSettingAction = computed(() => activeTab.value === 'market')
|
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(() => {
|
const pluginDynamicMenuItems = computed(() => {
|
||||||
if (!appMode.value) return undefined
|
if (!appMode.value) return undefined
|
||||||
if (!showSearchAction.value) return undefined
|
if (!showSearchAction.value) return undefined
|
||||||
|
|
||||||
const items = [
|
const items: DynamicButtonMenuItem[] = [
|
||||||
{
|
{
|
||||||
titleKey: 'plugin.searchPlugins',
|
titleKey: 'plugin.searchPlugins',
|
||||||
icon: 'mdi-magnify',
|
icon: 'mdi-magnify',
|
||||||
|
permission: 'admin',
|
||||||
action: openPluginSearchDialog,
|
action: openPluginSearchDialog,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -1077,6 +1091,7 @@ const pluginDynamicMenuItems = computed(() => {
|
|||||||
items.push({
|
items.push({
|
||||||
titleKey: 'plugin.newFolder',
|
titleKey: 'plugin.newFolder',
|
||||||
icon: 'mdi-folder-plus',
|
icon: 'mdi-folder-plus',
|
||||||
|
permission: 'admin',
|
||||||
action: showNewFolderDialog,
|
action: showNewFolderDialog,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1085,6 +1100,7 @@ const pluginDynamicMenuItems = computed(() => {
|
|||||||
items.push({
|
items.push({
|
||||||
titleKey: 'dialog.pluginMarketSetting.title',
|
titleKey: 'dialog.pluginMarketSetting.title',
|
||||||
icon: 'mdi-store-cog',
|
icon: 'mdi-store-cog',
|
||||||
|
permission: 'admin',
|
||||||
action: openMarketSettingDialog,
|
action: openMarketSettingDialog,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1096,6 +1112,7 @@ useDynamicButton({
|
|||||||
icon: 'mdi-magnify',
|
icon: 'mdi-magnify',
|
||||||
onClick: openPluginSearchDialog,
|
onClick: openPluginSearchDialog,
|
||||||
menuItems: pluginDynamicMenuItems,
|
menuItems: pluginDynamicMenuItems,
|
||||||
|
permission: 'admin',
|
||||||
show: computed(() => appMode.value && showSearchAction.value && isRefreshed.value),
|
show: computed(() => appMode.value && showSearchAction.value && isRefreshed.value),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1831,7 +1848,7 @@ function onDragStartPlugin(evt: any) {
|
|||||||
|
|
||||||
<!-- 插件搜索图标 -->
|
<!-- 插件搜索图标 -->
|
||||||
<Teleport to="body" v-if="route.path === '/plugins'">
|
<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
|
<VFab
|
||||||
v-if="showMarketSettingAction"
|
v-if="showMarketSettingAction"
|
||||||
icon="mdi-store-cog"
|
icon="mdi-store-cog"
|
||||||
|
|||||||
@@ -131,10 +131,10 @@ function determineBrowserInitialParams(downloadDirectories: TransferDirectoryCon
|
|||||||
async function loadDownloadDirectories() {
|
async function loadDownloadDirectories() {
|
||||||
try {
|
try {
|
||||||
// fetch available storages
|
// 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 ?? []
|
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) {
|
if (result.success && result.data?.value) {
|
||||||
const { storage, path, name } = determineBrowserInitialParams(result.data.value)
|
const { storage, path, name } = determineBrowserInitialParams(result.data.value)
|
||||||
// operItem初始化
|
// operItem初始化
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ import { useDisplay } from 'vuetify'
|
|||||||
import { formatFileSize } from '@/@core/utils/formatters'
|
import { formatFileSize } from '@/@core/utils/formatters'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { usePWA } from '@/composables/usePWA'
|
import { usePWA } from '@/composables/usePWA'
|
||||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
import { useDynamicButton, type DynamicButtonMenuItem } from '@/composables/useDynamicButton'
|
||||||
import { useAvailableHeight } from '@/composables/useAvailableHeight'
|
import { useAvailableHeight } from '@/composables/useAvailableHeight'
|
||||||
import { useBackground } from '@/composables/useBackground'
|
import { useBackground } from '@/composables/useBackground'
|
||||||
import { useGlobalSettingsStore } from '@/stores'
|
import { useGlobalSettingsStore, useUserStore } from '@/stores'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
|
|
||||||
const TransferHistoryDeleteDialog = defineAsyncComponent(
|
const TransferHistoryDeleteDialog = defineAsyncComponent(
|
||||||
() => import('@/components/dialog/TransferHistoryDeleteDialog.vue'),
|
() => import('@/components/dialog/TransferHistoryDeleteDialog.vue'),
|
||||||
@@ -42,6 +43,10 @@ const $toast = useToast()
|
|||||||
|
|
||||||
// 路由
|
// 路由
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const canManage = computed(() =>
|
||||||
|
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'manage'),
|
||||||
|
)
|
||||||
let syncingRouteQuery = false
|
let syncingRouteQuery = false
|
||||||
let fetchDataRequestSeed = 0
|
let fetchDataRequestSeed = 0
|
||||||
|
|
||||||
@@ -243,7 +248,7 @@ const storages = ref<StorageConf[]>([])
|
|||||||
// 查询存储
|
// 查询存储
|
||||||
async function loadStorages() {
|
async function loadStorages() {
|
||||||
try {
|
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 ?? []
|
storages.value = result.data?.value ?? []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -852,10 +857,11 @@ const historyDynamicIcon = computed(() => (selected.value.length > 0 ? 'mdi-chev
|
|||||||
const historyDynamicMenuItems = computed(() => {
|
const historyDynamicMenuItems = computed(() => {
|
||||||
if (selected.value.length === 0) return undefined
|
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',
|
titleKey: 'dialog.transferQueue.title',
|
||||||
icon: 'mdi-timer-sand-paused',
|
icon: 'mdi-timer-sand-paused',
|
||||||
|
permission: 'manage',
|
||||||
action: openTransferQueueDialog,
|
action: openTransferQueueDialog,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -865,6 +871,7 @@ const historyDynamicMenuItems = computed(() => {
|
|||||||
{
|
{
|
||||||
titleKey: 'transferHistory.actions.batchAiRedo',
|
titleKey: 'transferHistory.actions.batchAiRedo',
|
||||||
icon: 'mdi-robot-outline',
|
icon: 'mdi-robot-outline',
|
||||||
|
permission: 'manage',
|
||||||
action: () => {
|
action: () => {
|
||||||
triggerBatchAiRedo()
|
triggerBatchAiRedo()
|
||||||
},
|
},
|
||||||
@@ -872,6 +879,7 @@ const historyDynamicMenuItems = computed(() => {
|
|||||||
{
|
{
|
||||||
titleKey: 'transferHistory.actions.batchRedo',
|
titleKey: 'transferHistory.actions.batchRedo',
|
||||||
icon: 'mdi-redo-variant',
|
icon: 'mdi-redo-variant',
|
||||||
|
permission: 'manage',
|
||||||
action: () => {
|
action: () => {
|
||||||
retransferBatch()
|
retransferBatch()
|
||||||
},
|
},
|
||||||
@@ -880,6 +888,7 @@ const historyDynamicMenuItems = computed(() => {
|
|||||||
titleKey: 'transferHistory.actions.batchDelete',
|
titleKey: 'transferHistory.actions.batchDelete',
|
||||||
icon: 'mdi-trash-can-outline',
|
icon: 'mdi-trash-can-outline',
|
||||||
color: 'error',
|
color: 'error',
|
||||||
|
permission: 'manage',
|
||||||
action: () => {
|
action: () => {
|
||||||
removeHistoryBatch()
|
removeHistoryBatch()
|
||||||
},
|
},
|
||||||
@@ -894,6 +903,7 @@ useDynamicButton({
|
|||||||
icon: historyDynamicIcon,
|
icon: historyDynamicIcon,
|
||||||
onClick: openTransferQueueDialog,
|
onClick: openTransferQueueDialog,
|
||||||
menuItems: historyDynamicMenuItems,
|
menuItems: historyDynamicMenuItems,
|
||||||
|
permission: 'manage',
|
||||||
show: computed(() => appMode.value),
|
show: computed(() => appMode.value),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1173,7 +1183,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<!-- 非 app 模式下的 FAB 按钮 -->
|
<!-- 非 app 模式下的 FAB 按钮 -->
|
||||||
<Teleport to="body" v-if="!appMode && route.path === '/history'">
|
<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
|
<VFab
|
||||||
v-if="selected.length > 0 && !hasRunningAiRedo"
|
v-if="selected.length > 0 && !hasRunningAiRedo"
|
||||||
icon="mdi-trash-can-outline"
|
icon="mdi-trash-can-outline"
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ function orderDirectoryCards() {
|
|||||||
// 查询存储
|
// 查询存储
|
||||||
async function loadStorages() {
|
async function loadStorages() {
|
||||||
try {
|
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 ?? []
|
storages.value = result.data?.value ?? []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -150,7 +150,7 @@ async function saveStorages() {
|
|||||||
// 查询目录
|
// 查询目录
|
||||||
async function loadDirectories() {
|
async function loadDirectories() {
|
||||||
try {
|
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 ?? []
|
directories.value = result.data?.value ?? []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ async function queryFilterRuleGroups() {
|
|||||||
// 查询用户选中的站点
|
// 查询用户选中的站点
|
||||||
async function querySelectedSites() {
|
async function querySelectedSites() {
|
||||||
try {
|
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 ?? []
|
selectedSites.value = result.data?.value ?? []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import type { Site, SiteUserData } from '@/api/types'
|
|||||||
import SiteCard from '@/components/cards/SiteCard.vue'
|
import SiteCard from '@/components/cards/SiteCard.vue'
|
||||||
import NoDataFound from '@/components/NoDataFound.vue'
|
import NoDataFound from '@/components/NoDataFound.vue'
|
||||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.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 { useI18n } from 'vue-i18n'
|
||||||
import { usePWA } from '@/composables/usePWA'
|
import { usePWA } from '@/composables/usePWA'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh'
|
import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
|
import { useUserStore } from '@/stores'
|
||||||
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -22,6 +24,10 @@ const route = useRoute()
|
|||||||
|
|
||||||
// APP 模式检测
|
// APP 模式检测
|
||||||
const { appMode } = usePWA()
|
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))
|
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
|
||||||
@@ -333,28 +339,32 @@ watch(
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
const shouldShowFloatingActions = computed(() => route.path === '/site' && isRefreshed.value)
|
const shouldShowFloatingActions = computed(() => route.path === '/site' && isRefreshed.value && canManage.value)
|
||||||
|
|
||||||
// App 模式下将站点操作收纳到 Footer 动态菜单中,和插件页保持一致。
|
// App 模式下将站点操作收纳到 Footer 动态菜单中,和插件页保持一致。
|
||||||
const siteDynamicMenuItems = computed(() => [
|
const siteDynamicMenuItems = computed<DynamicButtonMenuItem[]>(() => [
|
||||||
{
|
{
|
||||||
titleKey: 'site.actions.add',
|
titleKey: 'site.actions.add',
|
||||||
icon: 'mdi-web-plus',
|
icon: 'mdi-web-plus',
|
||||||
|
permission: 'manage',
|
||||||
action: openSiteAddDialog,
|
action: openSiteAddDialog,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
titleKey: 'site.actions.import',
|
titleKey: 'site.actions.import',
|
||||||
icon: 'mdi-import',
|
icon: 'mdi-import',
|
||||||
|
permission: 'manage',
|
||||||
action: openSiteImportDialog,
|
action: openSiteImportDialog,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
titleKey: 'site.actions.export',
|
titleKey: 'site.actions.export',
|
||||||
icon: 'mdi-export',
|
icon: 'mdi-export',
|
||||||
|
permission: 'manage',
|
||||||
action: exportSites,
|
action: exportSites,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
titleKey: 'site.statistics',
|
titleKey: 'site.statistics',
|
||||||
icon: 'mdi-chart-line',
|
icon: 'mdi-chart-line',
|
||||||
|
permission: 'manage',
|
||||||
action: openSiteStatisticsDialog,
|
action: openSiteStatisticsDialog,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
@@ -364,6 +374,7 @@ useDynamicButton({
|
|||||||
icon: 'mdi-web-plus',
|
icon: 'mdi-web-plus',
|
||||||
onClick: openSiteAddDialog,
|
onClick: openSiteAddDialog,
|
||||||
menuItems: siteDynamicMenuItems,
|
menuItems: siteDynamicMenuItems,
|
||||||
|
permission: 'manage',
|
||||||
show: computed(() => appMode.value && shouldShowFloatingActions.value),
|
show: computed(() => appMode.value && shouldShowFloatingActions.value),
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ async function saveTransferExcludeWords() {
|
|||||||
// 查询集数定位规则
|
// 查询集数定位规则
|
||||||
async function queryEpisodeFormatRules() {
|
async function queryEpisodeFormatRules() {
|
||||||
try {
|
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) {
|
if (result && result.data && result.data.value) {
|
||||||
episodeFormatRules.value = normalizeEpisodeFormatRules(result.data.value)
|
episodeFormatRules.value = normalizeEpisodeFormatRules(result.data.value)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { useDynamicButton } from '@/composables/useDynamicButton'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { usePWA } from '@/composables/usePWA'
|
import { usePWA } from '@/composables/usePWA'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
|
import { useUserStore } from '@/stores'
|
||||||
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
|
|
||||||
const UserAddEditDialog = defineAsyncComponent(() => import('@/components/dialog/UserAddEditDialog.vue'))
|
const UserAddEditDialog = defineAsyncComponent(() => import('@/components/dialog/UserAddEditDialog.vue'))
|
||||||
|
|
||||||
@@ -16,6 +18,10 @@ const { t } = useI18n()
|
|||||||
|
|
||||||
// 路由
|
// 路由
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const canAdmin = computed(() =>
|
||||||
|
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
|
||||||
|
)
|
||||||
|
|
||||||
// PWA模式检测
|
// PWA模式检测
|
||||||
const { appMode } = usePWA()
|
const { appMode } = usePWA()
|
||||||
@@ -79,6 +85,7 @@ useDynamicButton({
|
|||||||
onClick: () => {
|
onClick: () => {
|
||||||
openAddUserDialog()
|
openAddUserDialog()
|
||||||
},
|
},
|
||||||
|
permission: 'admin',
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -110,7 +117,7 @@ useDynamicButton({
|
|||||||
|
|
||||||
<!-- 新增用户按钮 -->
|
<!-- 新增用户按钮 -->
|
||||||
<Teleport to="body" v-if="route.path === '/user'">
|
<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
|
<VFab
|
||||||
icon="mdi-account-plus"
|
icon="mdi-account-plus"
|
||||||
color="primary"
|
color="primary"
|
||||||
|
|||||||
Reference in New Issue
Block a user