feat: 增加工作流和知识库三级权限

- 抽取统一资源访问骨架与部门可见范围判断

- 接入工作流和知识库的 READ/MANAGE 权限校验

- 增加可见范围配置与只读态前端交互
This commit is contained in:
2026-03-29 17:25:55 +08:00
parent f49d94e2fe
commit 22ceabff96
58 changed files with 3053 additions and 85 deletions

View File

@@ -1,37 +1,123 @@
<script setup lang="ts">
import type {FormInstance} from 'element-plus';
import {ElForm, ElFormItem, ElInput, ElInputNumber, ElMessage, ElMessageBox,} from 'element-plus';
import type { FormInstance } from 'element-plus';
import type {ActionButton, CardPrimaryAction,} from '#/components/page/CardList.vue';
import CardPage from '#/components/page/CardList.vue';
import type {
ActionButton,
CardPrimaryAction,
} from '#/components/page/CardList.vue';
import {computed, onMounted, ref} from 'vue';
import {useRouter} from 'vue-router';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import {EasyFlowFormModal} from '@easyflow/common-ui';
import {$t} from '@easyflow/locales';
import { useAccess } from '@easyflow/access';
import { EasyFlowFormModal } from '@easyflow/common-ui';
import { $t } from '@easyflow/locales';
import { useUserStore } from '@easyflow/stores';
import {Delete, Edit, Notebook, Plus, Search} from '@element-plus/icons-vue';
import {tryit} from 'radash';
import {
Check,
Delete,
Edit,
Lock,
Notebook,
OfficeBuilding,
Plus,
Promotion,
Search,
} from '@element-plus/icons-vue';
import {
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElInputNumber,
ElMessage,
ElMessageBox,
ElPopover,
} from 'element-plus';
import { tryit } from 'radash';
import {api} from '#/api/request';
import { api } from '#/api/request';
import defaultIcon from '#/assets/ai/knowledge/book.svg';
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
import CardPage from '#/components/page/CardList.vue';
import PageData from '#/components/page/PageData.vue';
import PageSide from '#/components/page/PageSide.vue';
import DocumentCollectionModal from '#/views/ai/documentCollection/DocumentCollectionModal.vue';
const router = useRouter();
const userStore = useUserStore();
const { hasAccessByCodes } = useAccess();
const collectionTypeLabelMap = {
DOCUMENT: $t('documentCollection.collectionTypeDocument'),
FAQ: $t('documentCollection.collectionTypeFaq'),
};
type VisibilityScope = 'DEPT' | 'PRIVATE' | 'PUBLIC';
const canManageKnowledgePermission = computed(() =>
hasAccessByCodes(['/api/v1/documentCollection/save']),
);
const updatingScopeId = ref<null | number | string>(null);
const visibilityScopePopoverRefs = ref<Record<string, any>>({});
const visibilityScopeMeta = computed(() => ({
PRIVATE: {
label: $t('documentCollection.visibilityScopePrivate'),
description: $t('documentCollection.visibilityScopePrivateDesc'),
icon: Lock,
tone: 'private',
},
DEPT: {
label: $t('documentCollection.visibilityScopeDept'),
description: $t('documentCollection.visibilityScopeDeptDesc'),
icon: OfficeBuilding,
tone: 'dept',
},
PUBLIC: {
label: $t('documentCollection.visibilityScopePublic'),
description: $t('documentCollection.visibilityScopePublicDesc'),
icon: Promotion,
tone: 'public',
},
}));
const visibilityScopeOptions = computed(() =>
(['PRIVATE', 'DEPT', 'PUBLIC'] as VisibilityScope[]).map((value) => ({
value,
...visibilityScopeMeta.value[value],
})),
);
function isSuperAdmin() {
return (
String(userStore.userInfo?.id || '') === '1' ||
(userStore.userRoles || []).includes('super_admin')
);
}
function canManageKnowledgeItem(row: Record<string, any>) {
if (!canManageKnowledgePermission.value) {
return false;
}
const currentUserId = String(userStore.userInfo?.id || '');
return isSuperAdmin() || currentUserId === String(row?.createdBy || '');
}
function ensureManageKnowledgeItem(row: Record<string, any>) {
if (canManageKnowledgeItem(row)) {
return true;
}
ElMessage.warning($t('documentCollection.managePermissionHint'));
return false;
}
function resolveNavTitle(row: Record<string, any>) {
return row?.title || row?.name || '';
}
function openKnowledgeDetail(row: { id: string; name?: string; title?: string }) {
function openKnowledgeDetail(row: {
id: string;
name?: string;
title?: string;
}) {
router.push({
path: '/ai/documentCollection/document',
query: {
@@ -56,7 +142,7 @@ interface FieldDefinition {
const primaryAction: CardPrimaryAction = {
icon: Notebook,
text: $t('documentCollection.actions.knowledge'),
permission: '/api/v1/documentCollection/save',
permission: '/api/v1/documentCollection/query',
onClick(row) {
openKnowledgeDetail(row);
},
@@ -69,6 +155,9 @@ const actions: ActionButton[] = [
permission: '/api/v1/documentCollection/save',
placement: 'inline',
onClick(row) {
if (!ensureManageKnowledgeItem(row)) {
return;
}
aiKnowledgeModalRef.value.openDialog(row);
},
},
@@ -95,6 +184,9 @@ const actions: ActionButton[] = [
permission: '/api/v1/documentCollection/remove',
placement: 'inline',
onClick(row) {
if (!ensureManageKnowledgeItem(row)) {
return;
}
handleDelete(row);
},
},
@@ -178,6 +270,9 @@ const formRules = computed(() => {
const handleSearch = (params: any) => {
pageDataRef.value.setQuery({ title: params, isQueryOr: true });
};
const reloadKnowledgeList = () => {
pageDataRef.value?.reload?.();
};
const formData = ref<any>({});
const dialogVisible = ref(false);
const formRef = ref<FormInstance>();
@@ -189,7 +284,7 @@ function showControlDialog(item: any) {
const categoryList = ref<any[]>([]);
const getCategoryList = async () => {
const [, res] = await tryit(api.get)(
'/api/v1/documentCollectionCategory/list',
'/api/v1/documentCollectionCategory/visibleList',
{
params: { sortKey: 'sortNo', sortType: 'asc' },
},
@@ -258,6 +353,51 @@ const footerButton = {
},
};
const saveLoading = ref(false);
function resolveVisibilityScopeMeta(scope?: string) {
return (
visibilityScopeMeta.value[(scope || 'PRIVATE') as VisibilityScope] ||
visibilityScopeMeta.value.PRIVATE
);
}
function setVisibilityScopePopoverRef(id: number | string, el: any) {
const cacheKey = String(id);
if (el) {
visibilityScopePopoverRefs.value[cacheKey] = el;
return;
}
delete visibilityScopePopoverRefs.value[cacheKey];
}
function closeVisibilityScopePopover(id: number | string) {
visibilityScopePopoverRefs.value[String(id)]?.hide?.();
}
async function updateVisibilityScope(
row: any,
visibilityScope: VisibilityScope,
) {
if (
!canManageKnowledgeItem(row) ||
!row?.id ||
updatingScopeId.value === row.id ||
row.visibilityScope === visibilityScope
) {
closeVisibilityScopePopover(row.id);
return;
}
updatingScopeId.value = row.id;
try {
const res = await api.post('/api/v1/documentCollection/update', {
id: row.id,
visibilityScope,
});
if (res.errorCode === 0) {
row.visibilityScope = visibilityScope;
ElMessage.success($t('message.updateOkMessage'));
closeVisibilityScopePopover(row.id);
}
} finally {
updatingScopeId.value = null;
}
}
function handleSubmit() {
formRef.value?.validate((valid) => {
if (valid) {
@@ -318,7 +458,97 @@ function changeCategory(category: any) {
:actions="actions"
tag-field="collectionType"
:tag-map="collectionTypeLabelMap"
/>
>
<template #corner="{ item }">
<ElPopover
v-if="canManageKnowledgeItem(item)"
:ref="(el) => setVisibilityScopePopoverRef(item.id, el)"
trigger="click"
placement="bottom-end"
:width="208"
popper-class="knowledge-visibility-popover"
>
<template #reference>
<button
type="button"
class="knowledge-scope-chip"
:class="`knowledge-scope-chip--${resolveVisibilityScopeMeta(item.visibilityScope).tone}`"
:disabled="updatingScopeId === item.id"
@click.stop
>
<ElIcon class="knowledge-scope-chip__icon">
<component
:is="
resolveVisibilityScopeMeta(item.visibilityScope)
.icon
"
/>
</ElIcon>
<span class="knowledge-scope-chip__label">
{{
resolveVisibilityScopeMeta(item.visibilityScope).label
}}
</span>
</button>
</template>
<div class="knowledge-scope-panel" @click.stop>
<button
v-for="option in visibilityScopeOptions"
:key="option.value"
type="button"
class="knowledge-scope-option"
:class="[
`knowledge-scope-option--${option.tone}`,
{
'knowledge-scope-option--active':
item.visibilityScope === option.value,
},
]"
:disabled="updatingScopeId === item.id"
@click.stop="updateVisibilityScope(item, option.value)"
>
<span class="knowledge-scope-option__leading">
<span class="knowledge-scope-option__icon-wrap">
<ElIcon class="knowledge-scope-option__icon">
<component :is="option.icon" />
</ElIcon>
</span>
<span class="knowledge-scope-option__text">
<span class="knowledge-scope-option__label">
{{ option.label }}
</span>
<span class="knowledge-scope-option__desc">
{{ option.description }}
</span>
</span>
</span>
<ElIcon
v-if="item.visibilityScope === option.value"
class="knowledge-scope-option__check"
>
<Check />
</ElIcon>
</button>
</div>
</ElPopover>
<div
v-else
class="knowledge-scope-chip knowledge-scope-chip--readonly"
:class="`knowledge-scope-chip--${resolveVisibilityScopeMeta(item.visibilityScope).tone}`"
>
<ElIcon class="knowledge-scope-chip__icon">
<component
:is="
resolveVisibilityScopeMeta(item.visibilityScope).icon
"
/>
</ElIcon>
<span class="knowledge-scope-chip__label">
{{ resolveVisibilityScopeMeta(item.visibilityScope).label }}
</span>
</div>
</template>
</CardPage>
</template>
</PageData>
</div>
@@ -362,7 +592,10 @@ function changeCategory(category: any) {
</EasyFlowFormModal>
<!-- 新增知识库模态框-->
<DocumentCollectionModal ref="aiKnowledgeModalRef" @reload="handleSearch" />
<DocumentCollectionModal
ref="aiKnowledgeModalRef"
@reload="reloadKnowledgeList"
/>
</div>
</template>
@@ -372,4 +605,206 @@ h1 {
margin-bottom: 30px;
color: #303133;
}
.knowledge-scope-chip {
display: inline-flex;
gap: 8px;
align-items: center;
min-height: 30px;
padding: 0 12px;
font-size: 12px;
font-weight: 600;
line-height: 1;
color: hsl(var(--text-strong));
background: hsl(var(--surface-subtle) / 0.92);
border: 1px solid hsl(var(--line-subtle));
border-radius: 999px;
transition:
border-color 0.18s ease,
background-color 0.18s ease,
color 0.18s ease,
transform 0.18s ease,
box-shadow 0.18s ease;
}
button.knowledge-scope-chip {
cursor: pointer;
}
button.knowledge-scope-chip:hover {
transform: translateY(-1px);
box-shadow: 0 10px 22px -18px hsl(var(--foreground) / 0.32);
}
button.knowledge-scope-chip:focus-visible {
outline: none;
box-shadow:
0 0 0 4px hsl(var(--primary) / 0.12),
0 10px 22px -18px hsl(var(--foreground) / 0.32);
}
button.knowledge-scope-chip:disabled {
cursor: not-allowed;
opacity: 0.72;
transform: none;
}
.knowledge-scope-chip--readonly {
cursor: default;
}
.knowledge-scope-chip__icon {
font-size: 14px;
}
.knowledge-scope-chip__label {
white-space: nowrap;
}
.knowledge-scope-chip--private {
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.09);
border-color: hsl(var(--primary) / 0.2);
}
.knowledge-scope-chip--dept {
color: hsl(var(--warning));
background: hsl(var(--warning) / 0.12);
border-color: hsl(var(--warning) / 0.2);
}
.knowledge-scope-chip--public {
color: hsl(var(--success));
background: hsl(var(--success) / 0.12);
border-color: hsl(var(--success) / 0.2);
}
.knowledge-scope-panel {
display: flex;
flex-direction: column;
gap: 2px;
}
.knowledge-scope-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 100%;
padding: 10px 8px;
text-align: left;
background: transparent;
border: none;
border-radius: 12px;
transition:
background-color 0.18s ease,
transform 0.18s ease,
color 0.18s ease;
}
.knowledge-scope-option:hover {
background: hsl(var(--foreground) / 0.04);
}
.knowledge-scope-option:focus-visible {
outline: none;
box-shadow: 0 0 0 4px hsl(var(--primary) / 0.12);
}
.knowledge-scope-option:disabled {
cursor: not-allowed;
opacity: 0.72;
}
.knowledge-scope-option__leading {
display: flex;
gap: 8px;
align-items: center;
min-width: 0;
}
.knowledge-scope-option__icon-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 9px;
background: transparent;
}
.knowledge-scope-option__icon {
font-size: 15px;
}
.knowledge-scope-option__text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.knowledge-scope-option__label {
font-size: 13px;
font-weight: 600;
line-height: 1.3;
color: hsl(var(--text-strong));
}
.knowledge-scope-option__desc {
font-size: 12px;
line-height: 1.4;
color: hsl(var(--text-muted));
}
.knowledge-scope-option__check {
font-size: 16px;
color: hsl(var(--primary));
}
.knowledge-scope-option--private .knowledge-scope-option__icon-wrap {
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.1);
}
.knowledge-scope-option--dept .knowledge-scope-option__icon-wrap {
color: hsl(var(--warning));
background: hsl(var(--warning) / 0.12);
}
.knowledge-scope-option--public .knowledge-scope-option__icon-wrap {
color: hsl(var(--success));
background: hsl(var(--success) / 0.12);
}
.knowledge-scope-option--private.knowledge-scope-option--active {
background: hsl(var(--primary) / 0.08);
}
.knowledge-scope-option--dept.knowledge-scope-option--active {
background: hsl(var(--warning) / 0.08);
}
.knowledge-scope-option--public.knowledge-scope-option--active {
background: hsl(var(--success) / 0.08);
}
.knowledge-scope-option--private .knowledge-scope-option__check {
color: hsl(var(--primary));
}
.knowledge-scope-option--dept .knowledge-scope-option__check {
color: hsl(var(--warning));
}
.knowledge-scope-option--public .knowledge-scope-option__check {
color: hsl(var(--success));
}
:global(.knowledge-visibility-popover.el-popover.el-popper) {
padding: 8px;
border-radius: 16px;
border-color: hsl(var(--line-subtle));
box-shadow: 0 18px 34px -28px hsl(var(--foreground) / 0.2);
}
</style>