From eb143c28e396951673a6be4afafce60f058b221a Mon Sep 17 00:00:00 2001 From: jxxghp Date: Tue, 10 Jun 2025 23:25:59 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=94=A8=E6=88=B7=E6=9D=83?= =?UTF-8?q?=E9=99=90=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PERMISSION_SYSTEM.md | 109 +++++++++++++++ src/components/cards/UserCard.vue | 33 ++++- src/components/dialog/UserAddEditDialog.vue | 141 +++++++++++++++++++- src/layouts/components/DefaultLayout.vue | 10 +- src/layouts/components/Footer.vue | 16 ++- src/layouts/components/SearchBar.vue | 20 ++- src/locales/zh-CN.ts | 13 ++ src/pages/appcenter.vue | 18 ++- src/stores/user.ts | 7 +- src/utils/permission.ts | 97 ++++++++++++++ 10 files changed, 445 insertions(+), 19 deletions(-) create mode 100644 PERMISSION_SYSTEM.md create mode 100644 src/utils/permission.ts diff --git a/PERMISSION_SYSTEM.md b/PERMISSION_SYSTEM.md new file mode 100644 index 00000000..7e66cf1a --- /dev/null +++ b/PERMISSION_SYSTEM.md @@ -0,0 +1,109 @@ +# MoviePilot 用户权限控制系统 + +## 概述 + +本系统实现了精细化的用户权限控制,将用户权限分为四个主要类别:发现、搜索、订阅、管理。用户登录后根据权限显示不同的菜单范围。 + +## 权限类型 + +### 1. 发现权限 (discovery) +- **功能**: 访问推荐和探索功能 +- **对应菜单**: 推荐、探索 +- **默认状态**: 启用 + +### 2. 搜索权限 (search) +- **功能**: 使用搜索功能和查看搜索结果 +- **对应菜单**: 搜索结果页面 +- **对应组件**: 顶部搜索栏 +- **默认状态**: 启用 + +### 3. 订阅权限 (subscribe) +- **功能**: 管理电影和电视剧订阅 +- **对应菜单**: 电影订阅、电视剧订阅、工作流、日历 +- **默认状态**: 启用 + +### 4. 管理权限 (manage) +- **功能**: 访问系统管理和设置功能 +- **对应菜单**: 系统设置、用户管理、站点管理、插件管理、文件管理、媒体整理、下载管理 +- **默认状态**: 禁用 + +## 实现细节 + +### 权限存储 +- 权限数据存储在 `userStore.permissions` 中,格式为 JSON 对象 +- 后端接口支持 `permissions` 字段的存储和读取 + +### 权限检查 +- 超级用户拥有所有权限,无视权限设置 +- 普通用户根据具体权限配置进行访问控制 +- 使用 `hasPermission()` 函数进行权限检查 + +### 菜单过滤 +- 使用 `filterMenusByPermission()` 函数根据用户权限过滤菜单项 +- 支持国际化菜单标题匹配 +- 自动隐藏无权限访问的菜单项 + +### 组件权限控制 +- 搜索栏根据搜索权限控制显示/隐藏 +- 各页面根据对应权限进行访问控制 + +## 使用方法 + +### 1. 新增用户时设置权限 +在用户编辑对话框中: +- 提供权限设置界面 +- 支持快速预设(普通用户/管理员) +- 可单独切换每个权限 + +### 2. 编辑现有用户权限 +- 管理员可以修改其他用户的权限 +- 用户无法修改自己的权限(防止权限提升) +- 权限变更立即生效 + +### 3. 权限预设 +- **普通用户**: 发现✓ 搜索✓ 订阅✓ 管理✗ +- **管理员**: 发现✓ 搜索✓ 订阅✓ 管理✓ + +## 文件修改清单 + +### 新增文件 +- `src/utils/permission.ts` - 权限管理工具函数 + +### 修改文件 +- `src/components/dialog/UserAddEditDialog.vue` - 添加权限设置界面 +- `src/stores/user.ts` - 添加权限默认值处理 +- `src/layouts/components/DefaultLayout.vue` - 根据权限过滤菜单 +- `src/layouts/components/SearchBar.vue` - 根据搜索权限控制显示 +- `src/layouts/components/Footer.vue` - 底部菜单权限过滤 +- `src/pages/appcenter.vue` - 应用中心权限过滤 +- `src/components/cards/UserCard.vue` - 显示用户权限信息 +- `src/locales/zh-CN.ts` - 添加权限相关国际化文本 + +## 权限检查示例 + +```typescript +import { hasPermission } from '@/utils/permission' +import { useUserStore } from '@/stores' + +const userStore = useUserStore() + +// 检查是否有搜索权限 +const hasSearchPermission = hasPermission({ + is_superuser: userStore.superUser, + ...userStore.permissions +}, 'search') + +// 检查是否有管理权限 +const hasManagePermission = hasPermission({ + is_superuser: userStore.superUser, + ...userStore.permissions +}, 'manage') +``` + +## 注意事项 + +1. 超级用户始终拥有所有权限 +2. 权限变更需要重新登录或刷新页面生效 +3. 权限数据以 JSON 格式存储在后端 +4. 前端权限检查仅用于界面控制,后端仍需进行权限验证 +5. 默认权限配置确保新用户有基本的使用权限 diff --git a/src/components/cards/UserCard.vue b/src/components/cards/UserCard.vue index 0827ed5e..33addb1f 100644 --- a/src/components/cards/UserCard.vue +++ b/src/components/cards/UserCard.vue @@ -183,6 +183,21 @@ onMounted(() => { 2FA + +
+ + {{ t('dialog.userAddEdit.permissions.discovery') }} + + + {{ t('dialog.userAddEdit.permissions.search') }} + + + {{ t('dialog.userAddEdit.permissions.subscribe') }} + + + {{ t('dialog.userAddEdit.permissions.manage') }} + +
@@ -294,9 +309,10 @@ onMounted(() => { z-index: 1; display: flex; align-items: center; - width: 100%; - top: 0; - padding: 8px 12px; + inline-size: 100%; + inset-block-start: 0; + padding-block: 8px; + padding-inline: 12px; } .admin-header { @@ -326,10 +342,12 @@ onMounted(() => { opacity: 0.6; transform: scale(0.95); } + 70% { opacity: 0.2; transform: scale(1.05); } + 100% { opacity: 0.6; transform: scale(0.95); @@ -340,19 +358,21 @@ onMounted(() => { position: absolute; z-index: 5; animation: float 3s ease-in-out infinite; - top: -10px; - left: -6px; - transform: rotate(-25deg); filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 40%)); + inset-block-start: -10px; + inset-inline-start: -6px; + transform: rotate(-25deg); } @keyframes float { 0% { transform: rotate(-25deg) translateY(0); } + 50% { transform: rotate(-25deg) translateY(-3px); } + 100% { transform: rotate(-25deg) translateY(0); } @@ -368,6 +388,7 @@ onMounted(() => { opacity: 0.9; transform: scale(1); } + 50% { opacity: 1; transform: scale(1.2); diff --git a/src/components/dialog/UserAddEditDialog.vue b/src/components/dialog/UserAddEditDialog.vue index 992fb8b7..914d6e91 100644 --- a/src/components/dialog/UserAddEditDialog.vue +++ b/src/components/dialog/UserAddEditDialog.vue @@ -65,6 +65,14 @@ interface ExtendedUser extends User { nickname?: string } +// 权限类型定义 +interface UserPermissions { + discovery: boolean // 发现权限 + search: boolean // 搜索权限 + subscribe: boolean // 订阅权限 + manage: boolean // 管理权限 +} + // 用户编辑表单数据 const userForm = ref({ id: 0, @@ -75,7 +83,12 @@ const userForm = ref({ is_superuser: false, avatar: avatar1, is_otp: false, - permissions: {}, + permissions: { + discovery: true, + search: true, + subscribe: true, + manage: false, + }, settings: { wechat_userid: null, telegram_userid: null, @@ -86,6 +99,59 @@ const userForm = ref({ nickname: '', // 昵称字段 }) +// 权限选项 +const permissionOptions = [ + { + key: 'discovery', + title: t('dialog.userAddEdit.permissions.discovery'), + description: t('dialog.userAddEdit.permissions.discoveryDesc'), + icon: 'mdi-star-outline', + }, + { + key: 'search', + title: t('dialog.userAddEdit.permissions.search'), + description: t('dialog.userAddEdit.permissions.searchDesc'), + icon: 'mdi-magnify', + }, + { + key: 'subscribe', + title: t('dialog.userAddEdit.permissions.subscribe'), + description: t('dialog.userAddEdit.permissions.subscribeDesc'), + icon: 'mdi-rss', + }, + { + key: 'manage', + title: t('dialog.userAddEdit.permissions.manage'), + description: t('dialog.userAddEdit.permissions.manageDesc'), + icon: 'mdi-cog-outline', + }, +] + +// 权限状态计算属性 +const userPermissions = computed({ + get: () => { + const permissions = userForm.value.permissions as UserPermissions + return { + discovery: permissions?.discovery ?? true, + search: permissions?.search ?? true, + subscribe: permissions?.subscribe ?? true, + manage: permissions?.manage ?? false, + } + }, + set: (value: UserPermissions) => { + userForm.value.permissions = value + }, +}) + +// 切换权限状态 +function togglePermission(key: keyof UserPermissions) { + const currentPermissions = userPermissions.value + userPermissions.value = { + ...currentPermissions, + [key]: !currentPermissions[key], + } +} + // 更新头像 function changeAvatar(file: Event) { const fileReader = new FileReader() @@ -164,6 +230,10 @@ async function addUser() { } userForm.value.password = newPassword.value } + + // 设置权限数据 + userForm.value.permissions = userPermissions.value + isAdding.value = true startNProgress() try { @@ -216,8 +286,10 @@ async function updateUser() { isUpdating.value = true startNProgress() try { - // 确保昵称保存,使用一个临时变量存储完整数据 + // 确保昵称和权限保存,使用一个临时变量存储完整数据 const userData = { ...userForm.value } + // 确保权限数据正确传递 + userData.permissions = userPermissions.value const result: { [key: string]: any } = await api.put('user/', userData) @@ -235,6 +307,10 @@ async function updateUser() { if (oldAvatar !== currentAvatar.value && isCurrentUser.value) { userStore.setAvatar(currentAvatar.value) } + // 如果是当前登录用户,更新权限信息 + if (isCurrentUser.value) { + userStore.setPermissions(userPermissions.value) + } emit('save') } else { if (oldUserName !== currentUserName.value) { @@ -473,6 +549,67 @@ onMounted(() => { /> + + {{ t('dialog.userAddEdit.permissions.title') }} + + +
+
+ + {{ t('dialog.userAddEdit.permissions.presetNormal') }} + + + {{ t('dialog.userAddEdit.permissions.presetAdmin') }} + +
+ + + + + + + +
+
+ {{ option.title }} + +
+
+ {{ option.description }} +
+
+
+
+
+
+
diff --git a/src/layouts/components/DefaultLayout.vue b/src/layouts/components/DefaultLayout.vue index d55c26bb..f255b757 100644 --- a/src/layouts/components/DefaultLayout.vue +++ b/src/layouts/components/DefaultLayout.vue @@ -12,6 +12,7 @@ import { getNavMenus } from '@/router/i18n-menu' import { NavMenu } from '@/@layouts/types' import { useDisplay } from 'vuetify' import { useI18n } from 'vue-i18n' +import { filterMenusByPermission } from '@/utils/permission' const display = useDisplay() const appMode = inject('pwaMode') @@ -23,6 +24,12 @@ const userStore = useUserStore() // 是否超级用户 let superUser = userStore.superUser +// 获取用户权限信息 +const userPermissions = computed(() => ({ + is_superuser: userStore.superUser, + ...userStore.permissions, +})) + // 开始菜单项 const startMenus = ref([]) @@ -42,7 +49,8 @@ const systemMenus = ref([]) const getMenuList = (header: string) => { // 使用国际化菜单 const menus = getNavMenus() - return menus.filter((item: NavMenu) => item.header === header && (superUser || !item.admin)) + const filteredMenus = filterMenusByPermission(menus, userPermissions.value, t) + return filteredMenus.filter((item: NavMenu) => item.header === header) } // 返回上一页 diff --git a/src/layouts/components/Footer.vue b/src/layouts/components/Footer.vue index a4ee273d..41ce7224 100644 --- a/src/layouts/components/Footer.vue +++ b/src/layouts/components/Footer.vue @@ -3,6 +3,8 @@ import { getNavMenus } from '@/router/i18n-menu' import { useDisplay } from 'vuetify' import { NavMenu } from '@/@layouts/types' import { useI18n } from 'vue-i18n' +import { useUserStore } from '@/stores' +import { filterMenusByPermission } from '@/utils/permission' const display = useDisplay() const appMode = inject('pwaMode') && display.mdAndDown.value @@ -13,8 +15,20 @@ const isEnglish = computed(() => locale.value === 'en-US') const route = useRoute() +// 用户Store +const userStore = useUserStore() + +// 获取用户权限信息 +const userPermissions = computed(() => ({ + is_superuser: userStore.superUser, + ...userStore.permissions, +})) + // 获取导航菜单 -const navMenus = computed(() => getNavMenus()) +const navMenus = computed(() => { + const allMenus = getNavMenus() + return filterMenusByPermission(allMenus, userPermissions.value, t) +}) // 根据当前路径获取匹配的菜单路径 function getMenuPathFromRoute(path: string): string { diff --git a/src/layouts/components/SearchBar.vue b/src/layouts/components/SearchBar.vue index ac3fef08..b8949972 100644 --- a/src/layouts/components/SearchBar.vue +++ b/src/layouts/components/SearchBar.vue @@ -3,12 +3,28 @@ import * as Mousetrap from 'mousetrap' import SearchBarDialog from '@/components/dialog/SearchBarDialog.vue' import { useDisplay } from 'vuetify' import { useI18n } from 'vue-i18n' +import { useUserStore } from '@/stores' +import { hasPermission } from '@/utils/permission' const display = useDisplay() const { t } = useI18n() +// 用户Store +const userStore = useUserStore() + const searchDialog = ref(false) +// 检查是否有搜索权限 +const hasSearchPermission = computed(() => { + return hasPermission( + { + is_superuser: userStore.superUser, + ...userStore.permissions, + }, + 'search', + ) +}) + // 注册快捷键 Mousetrap.bind(['command+k', 'ctrl+k'], openSearchDialog) @@ -28,7 +44,7 @@ const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))