feat: implement full RBAC role-based access control

Backend:
- deps.py: add require_admin, require_manager, require_labeler, require_guest_or_above
- user.py: all write endpoints require admin
- datasource.py: write/sync endpoints require admin
- metadata.py: sync endpoint requires admin
- classification.py: category/rule write requires admin; results query requires guest+ with data isolation
- project.py: GET requires manager with created_by filtering; DELETE checks ownership
- task.py: my-tasks requires labeler with assignee_id filtering; create-task requires manager
- dashboard.py: requires guest_or_above
- report.py: requires guest_or_above
- project_service: list_projects adds created_by filter; list_results adds project_ids filter

Frontend:
- stores/user.ts: add hasRole, hasAnyRole, isAdmin, isManager, isLabeler, isSuperadmin
- router/index.ts: add roles to route meta; beforeEach checks role permissions
- Layout.vue: filter menu routes by user roles
- System.vue: hide add/edit/delete buttons for non-admins
- DataSource.vue: hide add/edit/delete/sync buttons for non-admins
- Project.vue: hide add/delete buttons for non-admins
This commit is contained in:
hiderfong
2026-04-23 12:09:32 +08:00
parent 377e9cba22
commit 8b2bc84399
16 changed files with 245 additions and 59 deletions
+7 -1
View File
@@ -122,7 +122,13 @@ const pageTitle = computed(() => (route.meta?.title as string) || 'DataPointer')
const menuRoutes = computed(() => {
const layout = router.getRoutes().find((r) => r.name === 'Layout')
return layout?.children.filter((r) => r.meta?.title) || []
const routes = layout?.children.filter((r) => r.meta?.title) || []
// Filter by role permissions
return routes.filter((r) => {
const allowedRoles = r.meta?.roles as string[] | undefined
if (!allowedRoles || allowedRoles.length === 0) return true
return userStore.hasAnyRole(allowedRoles)
})
})
function handleCommand(cmd: string) {
+31 -12
View File
@@ -18,55 +18,55 @@ const routes = [
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/Dashboard.vue'),
meta: { title: '首页', icon: 'HomeFilled' },
meta: { title: '首页', icon: 'HomeFilled', roles: ['superadmin', 'admin', 'project_manager', 'labeler', 'reviewer', 'guest'] },
},
{
path: 'datasource',
name: 'DataSource',
component: () => import('@/views/datasource/DataSource.vue'),
meta: { title: '数据源管理', icon: 'DataLine' },
meta: { title: '数据源管理', icon: 'DataLine', roles: ['superadmin', 'admin', 'project_manager', 'labeler', 'reviewer', 'guest'] },
},
{
path: 'metadata',
name: 'Metadata',
component: () => import('@/views/metadata/Metadata.vue'),
meta: { title: '数据资产', icon: 'FolderOpened' },
meta: { title: '数据资产', icon: 'FolderOpened', roles: ['superadmin', 'admin', 'project_manager', 'labeler', 'reviewer', 'guest'] },
},
{
path: 'category',
name: 'Category',
component: () => import('@/views/category/Category.vue'),
meta: { title: '分类分级标准', icon: 'Collection' },
meta: { title: '分类分级标准', icon: 'Collection', roles: ['superadmin', 'admin', 'project_manager', 'labeler', 'reviewer', 'guest'] },
},
{
path: 'project',
name: 'Project',
component: () => import('@/views/project/Project.vue'),
meta: { title: '项目管理', icon: 'List' },
meta: { title: '项目管理', icon: 'List', roles: ['superadmin', 'admin', 'project_manager'] },
},
{
path: 'task',
name: 'Task',
component: () => import('@/views/task/Task.vue'),
meta: { title: '我的任务', icon: 'EditPen' },
meta: { title: '我的任务', icon: 'EditPen', roles: ['superadmin', 'admin', 'project_manager', 'labeler', 'reviewer'] },
},
{
path: 'classification',
name: 'Classification',
component: () => import('@/views/classification/Classification.vue'),
meta: { title: '分类分级结果', icon: 'DocumentChecked' },
meta: { title: '分类分级结果', icon: 'DocumentChecked', roles: ['superadmin', 'admin', 'project_manager', 'labeler', 'reviewer', 'guest'] },
},
{
path: 'report',
name: 'Report',
component: () => import('@/views/report/Report.vue'),
meta: { title: '报表统计', icon: 'TrendCharts' },
meta: { title: '报表统计', icon: 'TrendCharts', roles: ['superadmin', 'admin', 'project_manager'] },
},
{
path: 'system',
name: 'System',
component: () => import('@/views/system/System.vue'),
meta: { title: '系统管理', icon: 'Setting' },
meta: { title: '系统管理', icon: 'Setting', roles: ['superadmin', 'admin'] },
},
],
},
@@ -83,11 +83,30 @@ const router = createRouter({
router.beforeEach((to, from, next) => {
const userStore = useUserStore()
if (!to.meta.public && !userStore.token) {
next('/login')
} else {
// Public routes (login)
if (to.meta.public) {
next()
return
}
// Not logged in
if (!userStore.token) {
next('/login')
return
}
// Check role permissions
const allowedRoles = to.meta.roles as string[] | undefined
if (allowedRoles && allowedRoles.length > 0) {
const hasPermission = userStore.hasAnyRole(allowedRoles)
if (!hasPermission) {
next('/dashboard')
return
}
}
next()
})
export default router
+26
View File
@@ -8,6 +8,25 @@ export const useUserStore = defineStore('user', () => {
const userInfo = ref<UserInfo | null>(null)
const isLoggedIn = computed(() => !!token.value)
// Extract role codes from user info
const roleCodes = computed(() => {
if (!userInfo.value?.roles) return []
return userInfo.value.roles.map((r: any) => r.code)
})
const isSuperadmin = computed(() => roleCodes.value.includes('superadmin'))
const isAdmin = computed(() => isSuperadmin.value || roleCodes.value.includes('admin'))
const isManager = computed(() => isAdmin.value || roleCodes.value.includes('project_manager'))
const isLabeler = computed(() => isManager.value || roleCodes.value.includes('labeler') || roleCodes.value.includes('reviewer'))
function hasRole(code: string): boolean {
return roleCodes.value.includes(code)
}
function hasAnyRole(codes: string[]): boolean {
return codes.some((c) => roleCodes.value.includes(c))
}
async function login(username: string, password: string) {
const res = await apiLogin(username, password)
token.value = res.access_token
@@ -39,6 +58,13 @@ export const useUserStore = defineStore('user', () => {
token,
userInfo,
isLoggedIn,
roleCodes,
isSuperadmin,
isAdmin,
isManager,
isLabeler,
hasRole,
hasAnyRole,
login,
fetchUserInfo,
logout,
+6 -4
View File
@@ -2,7 +2,7 @@
<div class="page-container">
<div class="page-header">
<h2 class="page-title">数据源管理</h2>
<el-button type="primary" @click="handleAdd">
<el-button v-if="userStore.isAdmin" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增数据源
</el-button>
</div>
@@ -44,9 +44,9 @@
</el-table-column>
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
<el-button type="primary" link size="small" @click="handleSync(row)">同步元数据</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
<el-button v-if="userStore.isAdmin" type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
<el-button v-if="userStore.isAdmin" type="primary" link size="small" @click="handleSync(row)">同步元数据</el-button>
<el-button v-if="userStore.isAdmin" type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -114,9 +114,11 @@
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
import { getDataSources, createDataSource, updateDataSource, deleteDataSource, testConnection, syncMetadata } from '@/api/datasource'
import type { DataSourceItem, DataSourceForm } from '@/api/datasource'
const userStore = useUserStore()
const loading = ref(false)
const tableData = ref<DataSourceItem[]>([])
const page = ref(1)
+4 -2
View File
@@ -2,7 +2,7 @@
<div class="page-container">
<div class="page-header">
<h2 class="page-title">项目管理</h2>
<el-button type="primary" @click="handleAdd">
<el-button v-if="userStore.isAdmin" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新建项目
</el-button>
</div>
@@ -52,7 +52,7 @@
</div>
<div class="project-actions">
<el-button type="primary" size="small" @click="handleAutoClassify(p)">自动分类</el-button>
<el-button type="danger" link size="small" @click="handleDelete(p)">删除</el-button>
<el-button v-if="userStore.isAdmin" type="danger" link size="small" @click="handleDelete(p)">删除</el-button>
</div>
</div>
</el-col>
@@ -91,6 +91,7 @@
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
import { getProjects, createProject, deleteProject, autoClassifyProject } from '@/api/project'
import { getTemplates } from '@/api/classification'
import { getDataSources } from '@/api/datasource'
@@ -103,6 +104,7 @@ const pageSize = ref(20)
const total = ref(0)
const keyword = ref('')
const userStore = useUserStore()
const dialogVisible = ref(false)
const submitLoading = ref(false)
const formRef = ref()
+5 -3
View File
@@ -10,7 +10,7 @@
<el-button type="primary" size="small" @click="handleSearch">
<el-icon><Search /></el-icon>查询
</el-button>
<el-button type="success" size="small" @click="openAdd">
<el-button v-if="userStore.isAdmin" type="success" size="small" @click="openAdd">
<el-icon><Plus /></el-icon>新增用户
</el-button>
</div>
@@ -39,8 +39,8 @@
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" size="small" :disabled="row.is_superuser" @click="handleDelete(row)">
<el-button v-if="userStore.isAdmin" link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button v-if="userStore.isAdmin" link type="danger" size="small" :disabled="row.is_superuser" @click="handleDelete(row)">
删除
</el-button>
</template>
@@ -112,10 +112,12 @@
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
import { getUsers, createUser, updateUser, deleteUser } from '@/api/user'
import { getRoles, getDepts } from '@/api/user'
import type { UserItem, RoleItem, DeptItem } from '@/api/user'
const userStore = useUserStore()
const activeTab = ref('users')
const userList = ref<UserItem[]>([])
const userLoading = ref(false)