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'))
-
+
@@ -38,7 +54,7 @@ const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))
-
+