feat: 增加分类权限控制

- 新增角色分类授权模型与超级管理员配置接口

- 接入助手、插件、工作流、知识库、素材的分类可见性过滤

- 增加角色页分类权限树与插件多分类可见性支持
This commit is contained in:
2026-03-29 17:16:37 +08:00
parent aaf4c61ff8
commit f49d94e2fe
46 changed files with 1963 additions and 128 deletions

View File

@@ -9,10 +9,15 @@ import { api } from '#/api/request';
import { $t } from '#/locales';
// 定义组件属性
const props = defineProps({
// 直接传入树数据
data: {
type: Array<any>,
default: undefined,
},
// 获取树数据的URL
dataUrl: {
type: String,
required: true,
default: '',
},
// 已选择的节点数组(支持双向绑定)
modelValue: {
@@ -47,6 +52,10 @@ const props = defineProps({
type: Number,
default: 200,
},
disabled: {
type: Boolean,
default: false,
},
// 是否显示子节点数量
showCount: {
type: Boolean,
@@ -117,27 +126,38 @@ const buildNodeMap = (nodes: any) => {
});
};
function applyTreeData(data: any[]) {
treeData.value = Array.isArray(data) ? data : [];
nodeMap.value.clear();
buildNodeMap(treeData.value);
if (props.modelValue && props.modelValue.length > 0) {
nextTick(() => {
if (treeRef.value) {
treeRef.value.setCheckedKeys(props.modelValue);
}
});
}
}
watch(
() => props.data,
(newData) => {
if (newData !== undefined) {
applyTreeData(newData || []);
}
},
{ immediate: true, deep: true },
);
// 获取树数据
const fetchTreeData = async () => {
if (!props.dataUrl) return;
if (props.data !== undefined || !props.dataUrl) return;
loading.value = true;
try {
const res = await api.get(props.dataUrl);
treeData.value = res.data;
// 构建节点映射
nodeMap.value.clear();
buildNodeMap(res.data);
// 数据加载完成后,如果有选中值则设置
if (props.modelValue && props.modelValue.length > 0) {
nextTick(() => {
if (treeRef.value) {
treeRef.value.setCheckedKeys(props.modelValue);
}
});
}
applyTreeData(res.data);
} catch (error) {
console.error('get data error:', error);
ElMessage.error($t('message.getDataError'));
@@ -189,13 +209,14 @@ defineExpose({
// 组件挂载时获取数据
onMounted(() => {
fetchTreeData();
if (props.data === undefined) {
fetchTreeData();
}
});
</script>
<template>
<div class="tree-select">
<div class="tree-header"></div>
<div class="tree-select" :class="{ 'tree-select--disabled': disabled }">
<div class="tree-wrapper">
<ElTreeV2
ref="treeRef"
@@ -231,32 +252,129 @@ onMounted(() => {
<style scoped>
.tree-select {
width: 100%;
background-color: #fff;
border: 1px solid #e4e7ed;
border-radius: 6px;
}
.tree-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background-color: #f8f9fa;
border-bottom: 1px solid #e4e7ed;
overflow: hidden;
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--el-color-primary-light-9) 32%, var(--el-bg-color)) 0%,
var(--el-bg-color) 72%
);
border: 1px solid var(--el-border-color-lighter);
border-radius: 14px;
box-shadow: var(--el-box-shadow-lighter);
}
.tree-wrapper {
padding: 8px;
padding: 10px;
}
.tree-select--disabled {
background:
linear-gradient(
180deg,
var(--el-fill-color-extra-light) 0%,
var(--el-fill-color-blank) 72%
);
}
.tree-node {
display: flex;
gap: 8px;
align-items: center;
gap: 8px;
min-width: 0;
}
.node-label {
overflow: hidden;
color: var(--el-text-color-primary);
font-size: 14px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 20px;
padding: 0 6px;
color: var(--el-text-color-secondary);
font-size: 12px;
color: #909399;
line-height: 1;
background: var(--el-fill-color);
border-radius: 999px;
}
:deep(.el-tree) {
background: transparent;
color: inherit;
}
:deep(.el-tree-node__content) {
height: 38px;
margin-bottom: 4px;
padding: 0 10px;
border-radius: 10px;
transition:
background-color 0.18s ease,
color 0.18s ease;
}
:deep(.el-tree-node__content:hover) {
background: var(--el-fill-color-light);
}
:deep(.el-tree-node:focus > .el-tree-node__content) {
background: var(--el-color-primary-light-9);
}
:deep(.el-tree-node.is-current > .el-tree-node__content) {
background: color-mix(
in srgb,
var(--el-color-primary-light-8) 70%,
var(--el-bg-color)
);
}
:deep(.el-tree-node__expand-icon) {
color: var(--el-text-color-secondary);
}
:deep(.el-checkbox) {
margin-right: 8px;
}
:deep(.el-tree-node__children) {
overflow: visible;
}
:deep(.el-tree-node__children .el-tree-node__content) {
padding-left: 12px;
}
:deep(.el-scrollbar__bar.is-vertical) {
width: 8px;
}
:deep(.el-scrollbar__thumb) {
background: color-mix(
in srgb,
var(--el-text-color-secondary) 22%,
transparent
);
border-radius: 999px;
}
.tree-select--disabled :deep(.el-tree-node__content) {
cursor: not-allowed;
opacity: 0.72;
}
.tree-select--disabled :deep(.el-checkbox),
.tree-select--disabled :deep(.el-tree-node__expand-icon),
.tree-select--disabled :deep(.el-tree-node__content) {
pointer-events: none;
}
</style>

View File

@@ -12,6 +12,11 @@
"isDeleted": "IsDeleted",
"menuPermission": "MenuPermission",
"dataPermission": "DataPermission",
"categoryPermission": "CategoryPermission",
"categoryPermissionHint": "Deny by default. Applies to resource visibility and selectors/binders only.",
"categoryScopeAll": "AllCategories",
"categoryScopeCustom": "CustomCategories",
"categoryScopePlaceholder": "Select categories",
"checkStrictlyTrue": "Linked",
"checkStrictlyFalse": "NotLinked"
}

View File

@@ -12,6 +12,11 @@
"isDeleted": "删除标识",
"menuPermission": "菜单权限",
"dataPermission": "数据权限",
"categoryPermission": "分类权限",
"categoryPermissionHint": "未配置即拒绝,仅控制资源可见性与选择器/绑定器。",
"categoryScopeAll": "全部分类",
"categoryScopeCustom": "自定义分类",
"categoryScopePlaceholder": "请选择分类",
"checkStrictlyTrue": "联动",
"checkStrictlyFalse": "不联动"
}

View File

@@ -257,7 +257,7 @@ function handleSubmit() {
});
}
const getSideList = async () => {
const [, res] = await tryit(api.get)('/api/v1/botCategory/list', {
const [, res] = await tryit(api.get)('/api/v1/botCategory/visibleList', {
params: { sortKey: 'sortNo', sortType: 'asc' },
});

View File

@@ -41,10 +41,10 @@ const authTypeList = ref<headersType[]>([
},
]);
onMounted(() => {
api.get('/api/v1/model/list?supportEmbed=true').then((res) => {
api.get('/api/v1/plugin/modelList?supportEmbed=true').then((res) => {
embeddingLlmList.value = res.data;
});
api.get('/api/v1/model/list?supportRerankerLlmList=true').then((res) => {
api.get('/api/v1/plugin/modelList?supportRerankerLlmList=true').then((res) => {
rerankerLlmList.value = res.data;
});
api.get('/api/v1/pluginCategory/list').then((res) => {

View File

@@ -124,7 +124,7 @@ const footerButton = {
},
};
const getPluginCategoryList = async () => {
return api.get('/api/v1/pluginCategory/list').then((res) => {
return api.get('/api/v1/pluginCategory/visibleList').then((res) => {
if (res.errorCode === 0) {
const serverCategories = Array.isArray(res.data)
? (res.data as PluginCategory[])

View File

@@ -230,7 +230,7 @@ function handleSideSubmit() {
});
}
const getSideList = async () => {
const [, res] = await tryit(api.get)('/api/v1/resourceCategory/list', {
const [, res] = await tryit(api.get)('/api/v1/resourceCategory/visibleList', {
params: { sortKey: 'sortNo', sortType: 'asc' },
});

View File

@@ -1,11 +1,16 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import { onMounted, ref } from 'vue';
import { computed, ref } from 'vue';
import { EasyFlowFormModal } from '@easyflow/common-ui';
import { ElForm, ElFormItem, ElInput, ElMessage, ElSwitch } from 'element-plus';
import {
ElForm,
ElFormItem,
ElInput,
ElMessage,
} from 'element-plus';
import { api } from '#/api/request';
import DictSelect from '#/components/dict/DictSelect.vue';
@@ -13,20 +18,57 @@ import Tree from '#/components/tree/Tree.vue';
import { $t } from '#/locales';
const emit = defineEmits(['reload']);
// vue
onMounted(() => {});
defineExpose({
openDialog,
});
type ResourceType = 'BOT' | 'KNOWLEDGE' | 'PLUGIN' | 'RESOURCE' | 'WORKFLOW';
interface CategoryScopeItem {
categoryIds: Array<number | string>;
resourceType: ResourceType;
scopeMode: 'ALL' | 'CUSTOM';
}
interface CategoryOption {
id: number | string;
label: string;
}
interface CategoryPermissionTreeNode {
children?: CategoryPermissionTreeNode[];
id: string;
label: string;
}
const RESOURCE_SCOPE_GROUPS: Array<{ label: string; resourceType: ResourceType }> =
[
{ resourceType: 'BOT', label: $t('bot.chatAssistant') },
{ resourceType: 'PLUGIN', label: $t('menus.ai.plugin') },
{ resourceType: 'WORKFLOW', label: $t('menus.ai.workflow') },
{ resourceType: 'KNOWLEDGE', label: $t('menus.ai.documentCollection') },
{ resourceType: 'RESOURCE', label: $t('menus.ai.resources') },
];
const saveForm = ref<FormInstance>();
// variables
const dialogVisible = ref(false);
const isAdd = ref(true);
const entity = ref<any>({
roleName: '',
roleKey: '',
status: '',
remark: '',
const entity = ref<any>(buildDefaultEntity());
const categoryScopeLoaded = ref(false);
const categoryScopeEditable = ref(false);
const categoryOptions = ref<Record<ResourceType, CategoryOption[]>>({
BOT: [],
KNOWLEDGE: [],
PLUGIN: [],
RESOURCE: [],
WORKFLOW: [],
});
const categoryTreeCheckedKeys = ref<string[]>([]);
const categoryScopeDetail = ref<{
roleId?: number | string;
scopes: CategoryScopeItem[];
}>({
scopes: buildDefaultScopes(),
});
const btnLoading = ref(false);
const rules = ref({
@@ -40,50 +82,229 @@ const rules = ref({
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
});
// functions
function openDialog(row: any) {
const categoryPermissionTreeData = computed<CategoryPermissionTreeNode[]>(() =>
RESOURCE_SCOPE_GROUPS.map(({ label, resourceType }) => ({
id: buildGroupNodeKey(resourceType),
label,
children: categoryOptions.value[resourceType].map((option) => ({
id: buildCategoryNodeKey(resourceType, option.id),
label: option.label,
})),
})),
);
function buildDefaultEntity() {
return {
menuCheckStrictly: true,
menuIds: [],
remark: '',
roleKey: '',
roleName: '',
status: '',
};
}
function buildDefaultScopes(): CategoryScopeItem[] {
return RESOURCE_SCOPE_GROUPS.map(({ resourceType }) => ({
categoryIds: [],
resourceType,
scopeMode: 'CUSTOM',
}));
}
function openDialog(row: any = {}) {
entity.value = {
...buildDefaultEntity(),
...row,
menuCheckStrictly: true,
};
categoryScopeDetail.value = {
roleId: row.id,
scopes: buildDefaultScopes(),
};
categoryScopeEditable.value = false;
syncCategoryTreeCheckedKeys(buildDefaultScopes());
if (row.id) {
isAdd.value = false;
getMenuIds(row.id);
getDeptIds(row.id);
}
entity.value = row;
getCategoryScopeDetail(row.id);
void ensureCategoryOptions();
dialogVisible.value = true;
}
function save() {
saveForm.value?.validate((valid) => {
saveForm.value?.validate(async (valid) => {
if (valid) {
btnLoading.value = true;
api
.post('/api/v1/sysRole/saveRole', entity.value)
.then((res) => {
btnLoading.value = false;
if (res.errorCode === 0) {
ElMessage.success(res.message);
emit('reload');
closeDialog();
}
})
.catch(() => {
btnLoading.value = false;
try {
const res = await api.post('/api/v1/sysRole/saveRole', {
...entity.value,
menuCheckStrictly: true,
});
if (res.errorCode !== 0) {
btnLoading.value = false;
return;
}
const roleId = res.data || entity.value.id;
if (roleId && categoryScopeEditable.value) {
const scopeRes = await saveCategoryScope(roleId);
if (scopeRes.errorCode !== 0) {
btnLoading.value = false;
return;
}
}
btnLoading.value = false;
ElMessage.success(res.message);
emit('reload');
closeDialog();
} catch {
btnLoading.value = false;
}
}
});
}
function closeDialog() {
saveForm.value?.resetFields();
isAdd.value = true;
entity.value = {};
entity.value = buildDefaultEntity();
categoryScopeDetail.value = {
scopes: buildDefaultScopes(),
};
categoryScopeEditable.value = false;
syncCategoryTreeCheckedKeys(buildDefaultScopes());
dialogVisible.value = false;
}
function getMenuIds(roleId: any) {
api.get(`/api/v1/sysRole/getRoleMenuIds?roleId=${roleId}`).then((res) => {
entity.value.menuIds = res.data;
});
}
function getDeptIds(roleId: any) {
api.get(`/api/v1/sysRole/getRoleDeptIds?roleId=${roleId}`).then((res) => {
entity.value.deptIds = res.data;
async function ensureCategoryOptions() {
if (categoryScopeLoaded.value) {
return;
}
const requests = [
api.get('/api/v1/botCategory/list', {
params: { sortKey: 'sortNo', sortType: 'asc' },
}),
api.get('/api/v1/pluginCategory/list'),
api.get('/api/v1/workflowCategory/list', {
params: { sortKey: 'sortNo', sortType: 'asc' },
}),
api.get('/api/v1/documentCollectionCategory/list', {
params: { sortKey: 'sortNo', sortType: 'asc' },
}),
api.get('/api/v1/resourceCategory/list', {
params: { sortKey: 'sortNo', sortType: 'asc' },
}),
];
const [botRes, pluginRes, workflowRes, knowledgeRes, resourceRes] =
await Promise.all(requests);
categoryOptions.value = {
BOT: normalizeCategoryOptions(botRes.data, 'categoryName'),
KNOWLEDGE: normalizeCategoryOptions(knowledgeRes.data, 'categoryName'),
PLUGIN: normalizeCategoryOptions(pluginRes.data, 'name'),
RESOURCE: normalizeCategoryOptions(resourceRes.data, 'categoryName'),
WORKFLOW: normalizeCategoryOptions(workflowRes.data, 'categoryName'),
};
categoryScopeLoaded.value = true;
}
function normalizeCategoryOptions(data: any[] = [], labelKey: string) {
return (Array.isArray(data) ? data : []).map((item) => ({
id: item.id,
label: item[labelKey],
}));
}
function buildGroupNodeKey(resourceType: ResourceType) {
return `group:${resourceType}`;
}
function buildCategoryNodeKey(
resourceType: ResourceType,
categoryId: number | string,
) {
return `category:${resourceType}:${String(categoryId)}`;
}
function syncCategoryTreeCheckedKeys(scopes: CategoryScopeItem[]) {
categoryTreeCheckedKeys.value = scopes.flatMap((scope) => {
if (scope.scopeMode === 'ALL') {
return [buildGroupNodeKey(scope.resourceType)];
}
return scope.categoryIds.map((categoryId) =>
buildCategoryNodeKey(scope.resourceType, categoryId),
);
});
}
function buildScopeItemsFromTree(): CategoryScopeItem[] {
const checkedKeySet = new Set(categoryTreeCheckedKeys.value);
return RESOURCE_SCOPE_GROUPS.map(({ resourceType }) => {
const groupNodeKey = buildGroupNodeKey(resourceType);
const grantedCategoryIds = categoryOptions.value[resourceType]
.filter((option) =>
checkedKeySet.has(buildCategoryNodeKey(resourceType, option.id)),
)
.map((option) => option.id);
const allCategoriesSelected =
categoryOptions.value[resourceType].length > 0 &&
grantedCategoryIds.length === categoryOptions.value[resourceType].length;
const scopeMode: CategoryScopeItem['scopeMode'] =
checkedKeySet.has(groupNodeKey) || allCategoriesSelected
? 'ALL'
: 'CUSTOM';
return {
categoryIds: scopeMode === 'ALL' ? [] : grantedCategoryIds,
resourceType,
scopeMode,
};
});
}
function getCategoryScopeDetail(roleId: number | string) {
api.get('/api/v1/sysRoleCategoryScope/detail', {
params: { roleId },
}).then((res) => {
if (res.errorCode !== 0) {
categoryScopeEditable.value = false;
categoryScopeDetail.value = {
roleId,
scopes: buildDefaultScopes(),
};
syncCategoryTreeCheckedKeys(categoryScopeDetail.value.scopes);
return;
}
categoryScopeEditable.value = !!res.data?.editable;
categoryScopeDetail.value = {
roleId,
scopes: (res.data?.scopes || buildDefaultScopes()).map((item: any) => ({
categoryIds: item.categoryIds || [],
resourceType: item.resourceType,
scopeMode: item.scopeMode || 'CUSTOM',
})),
};
syncCategoryTreeCheckedKeys(categoryScopeDetail.value.scopes);
});
}
async function saveCategoryScope(roleId: number | string) {
await ensureCategoryOptions();
const scopes = buildScopeItemsFromTree();
categoryScopeDetail.value = {
roleId,
scopes,
};
return api.post('/api/v1/sysRoleCategoryScope/save', {
roleId,
scopes,
});
}
</script>
@@ -120,11 +341,6 @@ function getDeptIds(roleId: any) {
<ElInput v-model.trim="entity.remark" />
</ElFormItem>
<ElFormItem :label="$t('sysRole.menuPermission')">
<ElSwitch
v-model="entity.menuCheckStrictly"
:active-text="$t('sysRole.checkStrictlyTrue')"
:inactive-text="$t('sysRole.checkStrictlyFalse')"
/>
<Tree
data-url="/api/v1/sysMenu/list?asTree=true"
v-model="entity.menuIds"
@@ -132,25 +348,23 @@ function getDeptIds(roleId: any) {
label: 'menuTitle',
children: 'children',
}"
:check-strictly="!entity.menuCheckStrictly"
:check-strictly="false"
/>
</ElFormItem>
<ElFormItem :label="$t('sysRole.dataPermission')">
<DictSelect v-model="entity.dataScope" dict-code="dataScope" />
<div v-if="entity.dataScope === 5" style="width: 100%">
<ElSwitch
v-model="entity.deptCheckStrictly"
:active-text="$t('sysRole.checkStrictlyTrue')"
:inactive-text="$t('sysRole.checkStrictlyFalse')"
/>
<ElFormItem :label="$t('sysRole.categoryPermission')">
<div class="role-category-scope-panel">
<Tree
data-url="/api/v1/sysDept/list?asTree=true"
v-model="entity.deptIds"
:data="categoryPermissionTreeData"
v-model="categoryTreeCheckedKeys"
:default-props="{
label: 'deptName',
label: 'label',
children: 'children',
}"
:check-strictly="!entity.deptCheckStrictly"
node-key="id"
:default-expand-all="true"
:check-strictly="false"
:height="280"
:disabled="!categoryScopeEditable"
/>
</div>
</ElFormItem>
@@ -158,4 +372,34 @@ function getDeptIds(roleId: any) {
</EasyFlowFormModal>
</template>
<style scoped></style>
<style scoped>
.role-category-scope-panel {
display: flex;
width: 100%;
flex-direction: column;
gap: 12px;
}
.role-category-scope-group {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
background: var(--el-fill-color-extra-light);
}
.role-category-scope-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.role-category-scope-title {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
</style>