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,20 +1,34 @@
<script setup lang="ts">
import type {FormInstance} from 'element-plus';
import {ElForm, ElFormItem, ElInput, ElInputNumber, ElMessage, ElMessageBox,} from 'element-plus';
import {
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElInputNumber,
ElMessage,
ElMessageBox,
ElPopover,
} from 'element-plus';
import type {ActionButton, CardPrimaryAction,} from '#/components/page/CardList.vue';
import CardList from '#/components/page/CardList.vue';
import {computed, markRaw, onMounted, ref} from 'vue';
import {useAccess} from '@easyflow/access';
import {EasyFlowFormModal} from '@easyflow/common-ui';
import {
Check,
CopyDocument,
Delete,
Download,
Edit,
Lock,
OfficeBuilding,
Plus,
Promotion,
Tickets,
Upload,
VideoPlay,
@@ -47,6 +61,8 @@ interface FieldDefinition {
placeholder?: string;
}
type VisibilityScope = 'PRIVATE' | 'DEPT' | 'PUBLIC';
const primaryAction: CardPrimaryAction = {
icon: DesignIcon,
text: $t('button.design'),
@@ -56,6 +72,39 @@ const primaryAction: CardPrimaryAction = {
},
};
const {hasAccessByCodes} = useAccess();
const canManageWorkflow = computed(() =>
hasAccessByCodes(['/api/v1/workflow/save']),
);
const updatingScopeId = ref<string | number | null>(null);
const visibilityScopePopoverRefs = ref<Record<string, any>>({});
const visibilityScopeMeta = computed(() => ({
PRIVATE: {
label: $t('aiWorkflow.visibilityScopePrivate'),
description: $t('aiWorkflow.visibilityScopePrivateDesc'),
icon: Lock,
tone: 'private',
},
DEPT: {
label: $t('aiWorkflow.visibilityScopeDept'),
description: $t('aiWorkflow.visibilityScopeDeptDesc'),
icon: OfficeBuilding,
tone: 'dept',
},
PUBLIC: {
label: $t('aiWorkflow.visibilityScopePublic'),
description: $t('aiWorkflow.visibilityScopePublicDesc'),
icon: Promotion,
tone: 'public',
},
}));
const visibilityScopeOptions = computed(() => {
return (['PRIVATE', 'DEPT', 'PUBLIC'] as VisibilityScope[]).map((value) => ({
value,
...visibilityScopeMeta.value[value],
}));
});
const actions: ActionButton[] = [
{
icon: Edit,
@@ -96,6 +145,7 @@ const actions: ActionButton[] = [
{
icon: Download,
text: $t('button.export'),
permission: '/api/v1/workflow/save',
placement: 'menu',
onClick: (row: any) => {
exportJson(row);
@@ -104,6 +154,7 @@ const actions: ActionButton[] = [
{
icon: CopyDocument,
text: $t('button.copy'),
permission: '/api/v1/workflow/save',
placement: 'menu',
onClick: (row: any) => {
showDialog({
@@ -151,6 +202,50 @@ const headerButtons = [
function initDict() {
dictStore.fetchDictionary('dataStatus');
}
function resolveVisibilityScopeMeta(scope?: string) {
return visibilityScopeMeta.value[(scope || 'PRIVATE') as VisibilityScope] ||
visibilityScopeMeta.value.PRIVATE;
}
function setVisibilityScopePopoverRef(id: string | number, el: any) {
const cacheKey = String(id);
if (el) {
visibilityScopePopoverRefs.value[cacheKey] = el;
return;
}
delete visibilityScopePopoverRefs.value[cacheKey];
}
function closeVisibilityScopePopover(id: string | number) {
visibilityScopePopoverRefs.value[String(id)]?.hide?.();
}
async function updateVisibilityScope(
row: any,
visibilityScope: VisibilityScope,
) {
if (
!canManageWorkflow.value ||
!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/workflow/update', {
id: row.id,
visibilityScope,
});
if (res.errorCode === 0) {
row.visibilityScope = visibilityScope;
ElMessage.success(res.message);
closeVisibilityScopePopover(row.id);
pageDataRef.value?.reload?.();
}
} finally {
updatingScopeId.value = null;
}
}
const handleSearch = (params: string) => {
pageDataRef.value.setQuery({ title: params, isQueryOr: true });
};
@@ -341,7 +436,7 @@ function handleSubmit() {
});
}
const getSideList = async () => {
const [, res] = await tryit(api.get)('/api/v1/workflowCategory/list', {
const [, res] = await tryit(api.get)('/api/v1/workflowCategory/visibleList', {
params: { sortKey: 'sortNo', sortType: 'asc' },
});
@@ -394,7 +489,90 @@ function handleHeaderButtonClick(data: any) {
:data="pageList"
:primary-action="primaryAction"
:actions="actions"
/>
>
<template #corner="{ item }">
<ElPopover
v-if="canManageWorkflow"
:ref="(el) => setVisibilityScopePopoverRef(item.id, el)"
trigger="click"
placement="bottom-end"
:width="208"
popper-class="workflow-visibility-popover"
>
<template #reference>
<button
type="button"
class="workflow-scope-chip"
:class="`workflow-scope-chip--${resolveVisibilityScopeMeta(item.visibilityScope).tone}`"
:disabled="updatingScopeId === item.id"
@click.stop
>
<ElIcon class="workflow-scope-chip__icon">
<component
:is="resolveVisibilityScopeMeta(item.visibilityScope).icon"
/>
</ElIcon>
<span class="workflow-scope-chip__label">
{{ resolveVisibilityScopeMeta(item.visibilityScope).label }}
</span>
</button>
</template>
<div class="workflow-scope-panel" @click.stop>
<button
v-for="option in visibilityScopeOptions"
:key="option.value"
type="button"
class="workflow-scope-option"
:class="[
`workflow-scope-option--${option.tone}`,
{
'workflow-scope-option--active':
item.visibilityScope === option.value,
},
]"
:disabled="updatingScopeId === item.id"
@click.stop="updateVisibilityScope(item, option.value)"
>
<span class="workflow-scope-option__leading">
<span class="workflow-scope-option__icon-wrap">
<ElIcon class="workflow-scope-option__icon">
<component :is="option.icon" />
</ElIcon>
</span>
<span class="workflow-scope-option__text">
<span class="workflow-scope-option__label">
{{ option.label }}
</span>
<span class="workflow-scope-option__desc">
{{ option.description }}
</span>
</span>
</span>
<ElIcon
v-if="item.visibilityScope === option.value"
class="workflow-scope-option__check"
>
<Check />
</ElIcon>
</button>
</div>
</ElPopover>
<div
v-else
class="workflow-scope-chip workflow-scope-chip--readonly"
:class="`workflow-scope-chip--${resolveVisibilityScopeMeta(item.visibilityScope).tone}`"
>
<ElIcon class="workflow-scope-chip__icon">
<component
:is="resolveVisibilityScopeMeta(item.visibilityScope).icon"
/>
</ElIcon>
<span class="workflow-scope-chip__label">
{{ resolveVisibilityScopeMeta(item.visibilityScope).label }}
</span>
</div>
</template>
</CardList>
</template>
</PageData>
</div>
@@ -440,4 +618,205 @@ function handleHeaderButtonClick(data: any) {
</div>
</template>
<style scoped></style>
<style scoped>
.workflow-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.workflow-scope-chip {
cursor: pointer;
}
button.workflow-scope-chip:hover {
transform: translateY(-1px);
box-shadow: 0 10px 22px -18px hsl(var(--foreground) / 0.32);
}
button.workflow-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.workflow-scope-chip:disabled {
cursor: not-allowed;
opacity: 0.72;
transform: none;
}
.workflow-scope-chip--readonly {
cursor: default;
}
.workflow-scope-chip__icon {
font-size: 14px;
}
.workflow-scope-chip__label {
white-space: nowrap;
}
.workflow-scope-chip--private {
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.09);
border-color: hsl(var(--primary) / 0.2);
}
.workflow-scope-chip--dept {
color: hsl(var(--warning));
background: hsl(var(--warning) / 0.12);
border-color: hsl(var(--warning) / 0.2);
}
.workflow-scope-chip--public {
color: hsl(var(--success));
background: hsl(var(--success) / 0.12);
border-color: hsl(var(--success) / 0.2);
}
.workflow-scope-panel {
display: flex;
flex-direction: column;
gap: 2px;
}
.workflow-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;
}
.workflow-scope-option:hover {
background: hsl(var(--foreground) / 0.04);
}
.workflow-scope-option:focus-visible {
outline: none;
box-shadow: 0 0 0 4px hsl(var(--primary) / 0.12);
}
.workflow-scope-option:disabled {
cursor: not-allowed;
opacity: 0.72;
}
.workflow-scope-option__leading {
display: flex;
gap: 8px;
align-items: center;
min-width: 0;
}
.workflow-scope-option__icon-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 9px;
background: transparent;
}
.workflow-scope-option__icon {
font-size: 15px;
}
.workflow-scope-option__text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.workflow-scope-option__label {
font-size: 13px;
font-weight: 600;
color: hsl(var(--text-strong));
}
.workflow-scope-option__desc {
font-size: 11px;
line-height: 1.35;
color: hsl(var(--text-muted));
}
.workflow-scope-option__check {
flex-shrink: 0;
font-size: 15px;
}
.workflow-scope-option--private .workflow-scope-option__icon-wrap {
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.1);
}
.workflow-scope-option--dept .workflow-scope-option__icon-wrap {
color: hsl(var(--warning));
background: hsl(var(--warning) / 0.12);
}
.workflow-scope-option--public .workflow-scope-option__icon-wrap {
color: hsl(var(--success));
background: hsl(var(--success) / 0.12);
}
.workflow-scope-option--private.workflow-scope-option--active {
background: hsl(var(--primary) / 0.08);
}
.workflow-scope-option--dept.workflow-scope-option--active {
background: hsl(var(--warning) / 0.08);
}
.workflow-scope-option--public.workflow-scope-option--active {
background: hsl(var(--success) / 0.08);
}
.workflow-scope-option--private .workflow-scope-option__check {
color: hsl(var(--primary));
}
.workflow-scope-option--dept .workflow-scope-option__check {
color: hsl(var(--warning));
}
.workflow-scope-option--public .workflow-scope-option__check {
color: hsl(var(--success));
}
:global(.workflow-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>

View File

@@ -5,7 +5,15 @@ import { computed, onMounted, ref } from 'vue';
import { EasyFlowFormModal } from '@easyflow/common-ui';
import { ElForm, ElFormItem, ElInput, ElMessage, ElUpload } from 'element-plus';
import {
ElForm,
ElFormItem,
ElInput,
ElMessage,
ElOption,
ElSelect,
ElUpload,
} from 'element-plus';
import { api } from '#/api/request';
import DictSelect from '#/components/dict/DictSelect.vue';
@@ -28,7 +36,7 @@ const isImport = ref(false);
const jsonFile = ref<any>(null);
const uploadFileList = ref<any[]>([]);
const uploadRef = ref<UploadInstance>();
const entity = ref<any>({
const createDefaultEntity = () => ({
alias: '',
deptId: '',
title: '',
@@ -36,8 +44,24 @@ const entity = ref<any>({
icon: '',
content: '',
englishName: '',
visibilityScope: 'PRIVATE',
});
const entity = ref<any>(createDefaultEntity());
const btnLoading = ref(false);
const visibilityScopeOptions = computed(() => [
{
label: $t('aiWorkflow.visibilityScopePrivate'),
value: 'PRIVATE',
},
{
label: $t('aiWorkflow.visibilityScopeDept'),
value: 'DEPT',
},
{
label: $t('aiWorkflow.visibilityScopePublic'),
value: 'PUBLIC',
},
]);
const jsonFileModel = computed({
get: () => (uploadFileList.value.length > 0 ? uploadFileList.value[0] : null),
set: (value: any) => {
@@ -57,10 +81,11 @@ const rules = computed(() => ({
// functions
function openDialog(row: any, importMode = false) {
isImport.value = importMode;
if (row.id) {
isAdd.value = false;
}
entity.value = row;
isAdd.value = !row?.id;
entity.value = {
...createDefaultEntity(),
...(row || {}),
};
dialogVisible.value = true;
}
@@ -137,7 +162,7 @@ function closeDialog() {
jsonFile.value = null;
isAdd.value = true;
isImport.value = false;
entity.value = {};
entity.value = createDefaultEntity();
dialogVisible.value = false;
}
</script>
@@ -198,6 +223,19 @@ function closeDialog() {
dict-code="aiWorkFlowCategory"
/>
</ElFormItem>
<ElFormItem
prop="visibilityScope"
:label="$t('aiWorkflow.visibilityScope')"
>
<ElSelect v-model="entity.visibilityScope" class="w-full">
<ElOption
v-for="item in visibilityScopeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem prop="alias" :label="$t('aiWorkflow.alias')">
<ElInput v-model.trim="entity.alias" />
</ElFormItem>