fix: enforce permission-aware navigation

This commit is contained in:
jxxghp
2026-06-09 21:45:51 +08:00
parent d0cac34d08
commit 4691d12faa
42 changed files with 483 additions and 239 deletions
+2
View File
@@ -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
View File
@@ -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)
+8 -1
View File
@@ -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
View File
@@ -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"
+1 -1
View File
@@ -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()
+3 -3
View File
@@ -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)
+24 -38
View File
@@ -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"
+13 -3
View File
@@ -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 ?? []
+1 -1
View File
@@ -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 ?? []
+6 -2
View File
@@ -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()
+2
View File
@@ -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属性
+1 -1
View File
@@ -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 || ''
+38 -21
View File
@@ -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>
+24 -9
View File
@@ -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" />
+13 -5
View File
@@ -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"
+25 -8
View File
@@ -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>
+2 -5
View File
@@ -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
View File
@@ -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"
+1
View File
@@ -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
View File
@@ -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) {
+8 -1
View File
@@ -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"
+8 -1
View File
@@ -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
View File
@@ -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
}, },
+9 -1
View File
@@ -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"
+5 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+10 -11
View File
@@ -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"
+22 -5
View File
@@ -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"
+2 -2
View File
@@ -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
+15 -5
View File
@@ -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)
+1 -1
View File
@@ -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) {
+14 -3
View File
@@ -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>
+1 -1
View File
@@ -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 -1
View File
@@ -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"