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

@@ -46,6 +46,9 @@ export interface CardListProps {
titleField?: string;
descField?: string;
actions?: ActionButton[];
cornerTagField?: string;
cornerTagMap?: Record<string, string>;
cornerTagTypeMap?: Record<string, string>;
defaultIcon: any;
data: any[];
primaryAction?: CardPrimaryAction;
@@ -58,6 +61,9 @@ const props = withDefaults(defineProps<CardListProps>(), {
titleField: 'title',
descField: 'description',
actions: () => [],
cornerTagField: '',
cornerTagMap: () => ({}),
cornerTagTypeMap: () => ({}),
primaryAction: undefined,
tagField: '',
tagMap: () => ({}),
@@ -154,6 +160,23 @@ function handleActionClick(event: Event, action: ActionButton, item: any) {
{{ item[descField] }}
</ElText>
</div>
<div
v-if="$slots.corner || (cornerTagField && item[cornerTagField])"
class="card-corner-tag"
>
<slot name="corner" :item="item">
<ElTag
size="small"
effect="plain"
:type="cornerTagTypeMap[item[cornerTagField]] || 'info'"
round
>
{{
cornerTagMap[item[cornerTagField]] || item[cornerTagField]
}}
</ElTag>
</slot>
</div>
</div>
</div>
@@ -343,6 +366,26 @@ function handleActionClick(event: Event, action: ActionButton, item: any) {
color: hsl(var(--text-muted));
}
.card-corner-tag {
display: flex;
flex-shrink: 0;
align-items: flex-start;
justify-content: flex-end;
min-height: 28px;
margin-left: auto;
}
.card-corner-tag :deep(.el-tag) {
--el-tag-border-radius: 999px;
--el-tag-font-size: 12px;
--el-tag-border-color: transparent;
padding: 0 10px;
font-weight: 600;
letter-spacing: 0.01em;
backdrop-filter: blur(6px);
}
.card-footer {
display: flex;
gap: 12px;

View File

@@ -14,6 +14,13 @@
"englishName": "EnglishName",
"status": "ShowInUserCenter",
"categoryId": "Category",
"visibilityScope": "Visibility Scope",
"visibilityScopePrivate": "Personal",
"visibilityScopePrivateDesc": "Only the creator can access it",
"visibilityScopeDept": "Dept",
"visibilityScopeDeptDesc": "Available to the dept and descendants",
"visibilityScopePublic": "Public",
"visibilityScopePublicDesc": "Available to internal users matched by category",
"params": "Params",
"steps": "Steps",
"result": "Result",

View File

@@ -10,6 +10,13 @@
"collectionTypeDocument": "Document",
"collectionTypeFaq": "FAQ",
"description": "Description",
"visibilityScope": "Visibility Scope",
"visibilityScopePrivate": "Personal",
"visibilityScopePrivateDesc": "Only the creator can access it",
"visibilityScopeDept": "Dept",
"visibilityScopeDeptDesc": "Available to the dept and descendants",
"visibilityScopePublic": "Public",
"visibilityScopePublicDesc": "Available to internal users matched by category",
"slug": "Slug",
"vectorStoreEnable": "VectorStoreEnable",
"vectorStoreType": "VectorStoreType",
@@ -46,12 +53,24 @@
},
"importDoc": {
"fileUpload": "File upload",
"parameterSettings": "ParameterSettings",
"parameterSettings": "Parameter settings",
"strategyAnalysis": "Strategy analysis",
"segmentedPreview": "SegmentedPreview",
"confirmImport": "ConfirmImport",
"fileName": "File Name",
"progressUpload": "Progress of file upload",
"fileSize": "File size"
"fileSize": "File size",
"analysisTip": "The system analyzes multilingual structure first and recommends a splitting strategy. You can still adjust each file manually.",
"confidence": "Confidence",
"recommendReason": "Reasons",
"candidateStrategies": "Candidates",
"strategySelection": "Strategy",
"previewTip": "The preview result is the final import basis. Confirm it before committing.",
"previewEmpty": "No preview data",
"warningCount": "Warnings",
"chunkCount": "Chunks",
"resultEmpty": "No import result",
"importFailed": "Import failed"
},
"splitterDoc": {
"fileType": "FileType",
@@ -64,6 +83,12 @@
"simpleTokenizeSplitter": "SimpleTokenizeSplitter",
"regexDocumentSplitter": "RegexDocumentSplitter",
"markdownHeaderSplitter": "MarkdownHeaderSplitter",
"autoStrategy": "Auto recommendation",
"markdownSection": "Markdown headings",
"outlineSection": "Outline sections",
"qaPair": "Q&A pairs",
"paragraphLength": "Paragraph length",
"customRegex": "Custom regex",
"mdSplitterLevel": "MarkdownSplitterLevel",
"uploadStatus": "UploadStatus",
"pendingUpload": "PendingUpload",
@@ -139,5 +164,6 @@
"tencentCloud": "tencentCloud",
"vectorEmbedModelTips": "After successful vector data, it is not allowed to modify the vector model",
"dimensionOfVectorModelTips": "After successful vector data, it is not allowed to modify the dimensions of the vector model",
"dimensionOfVectorModel": "Dimension of vector model"
"dimensionOfVectorModel": "Dimension of vector model",
"managePermissionHint": "Only the creator or super admin can modify this knowledge base"
}

View File

@@ -14,6 +14,13 @@
"englishName": "英文名称",
"status": "在用户中心显示",
"categoryId": "分类",
"visibilityScope": "可见范围",
"visibilityScopePrivate": "个人",
"visibilityScopePrivateDesc": "仅创建者可访问",
"visibilityScopeDept": "部门",
"visibilityScopeDeptDesc": "本部门及下级部门可访问",
"visibilityScopePublic": "公开",
"visibilityScopePublicDesc": "分类命中的内部用户可访问",
"params": "执行参数",
"steps": "执行步骤",
"result": "执行结果",

View File

@@ -10,6 +10,13 @@
"collectionTypeDocument": "文档",
"collectionTypeFaq": "FAQ",
"description": "描述",
"visibilityScope": "可见范围",
"visibilityScopePrivate": "个人",
"visibilityScopePrivateDesc": "仅创建者可访问",
"visibilityScopeDept": "部门",
"visibilityScopeDeptDesc": "本部门及下级部门可访问",
"visibilityScopePublic": "公开",
"visibilityScopePublicDesc": "分类命中的内部用户可访问",
"slug": "URL 别名",
"vectorStoreEnable": "是否启用向量数据库",
"vectorStoreType": "向量数据库类型",
@@ -47,11 +54,23 @@
"importDoc": {
"fileUpload": "文件上传",
"parameterSettings": "参数设置",
"strategyAnalysis": "策略分析",
"segmentedPreview": "分段预览",
"confirmImport": "确认导入",
"fileName": "文件名称",
"progressUpload": "文件上传进度",
"fileSize": "文件大小"
"fileSize": "文件大小",
"analysisTip": "系统会先基于文档结构做中英文规则分析,再推荐拆分策略,你也可以逐个文件手动调整。",
"confidence": "置信度",
"recommendReason": "推荐理由",
"candidateStrategies": "备选策略",
"strategySelection": "拆分策略",
"previewTip": "预览结果就是最终入库依据,确认无误后再执行导入。",
"previewEmpty": "暂无可预览内容",
"warningCount": "警告数",
"chunkCount": "分块数",
"resultEmpty": "暂无导入结果",
"importFailed": "导入失败"
},
"splitterDoc": {
"fileType": "文件类型",
@@ -64,6 +83,12 @@
"simpleTokenizeSplitter": "简单分词器",
"regexDocumentSplitter": "正则文档分割器",
"markdownHeaderSplitter": "Markdown标题层级拆分器",
"autoStrategy": "自动推荐",
"markdownSection": "Markdown 标题拆分",
"outlineSection": "章节标题拆分",
"qaPair": "问答对拆分",
"paragraphLength": "自然段长度拆分",
"customRegex": "自定义正则拆分",
"mdSplitterLevel": "Markdown标题等级",
"uploadStatus": "上传状态",
"pendingUpload": "待上传",
@@ -139,5 +164,6 @@
"tencentCloud": "腾讯云",
"vectorEmbedModelTips": "成功向量数据之后不允许修改向量模型",
"dimensionOfVectorModelTips": "成功向量数据之后不允许修改向量模型维度",
"dimensionOfVectorModel": "向量模型维度"
"dimensionOfVectorModel": "向量模型维度",
"managePermissionHint": "仅创建者或超级管理员可修改当前知识库"
}

View File

@@ -27,14 +27,26 @@ const props = defineProps({
type: String,
default: '',
},
manageable: {
type: Boolean,
default: true,
},
});
const dialogVisible = ref(false);
const pageDataRef = ref();
const handleEdit = (row: any) => {
if (!props.manageable) {
ElMessage.warning($t('documentCollection.managePermissionHint'));
return;
}
form.value = { id: row.id, content: row.content };
openDialog();
};
const handleDelete = (row: any) => {
if (!props.manageable) {
ElMessage.warning($t('documentCollection.managePermissionHint'));
return;
}
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
confirmButtonText: $t('message.ok'),
cancelButtonText: $t('message.cancel'),
@@ -68,6 +80,10 @@ const queryParams = ref({
sortType: 'asc',
});
const save = () => {
if (!props.manageable) {
ElMessage.warning($t('documentCollection.managePermissionHint'));
return;
}
btnLoading.value = true;
api.post('/api/v1/documentChunk/update', form.value).then((res: any) => {
btnLoading.value = false;
@@ -103,7 +119,12 @@ const form = ref({
:label="$t('documentCollection.content')"
min-width="240"
/>
<ElTableColumn :label="$t('common.handle')" width="100" align="right">
<ElTableColumn
v-if="props.manageable"
:label="$t('common.handle')"
width="100"
align="right"
>
<template #default="{ row }">
<div class="flex items-center gap-3">
<ElButton link type="primary" @click="handleEdit(row)">
@@ -130,6 +151,7 @@ const form = ref({
</template>
</PageData>
<EasyFlowFormModal
v-if="props.manageable"
v-model:open="dialogVisible"
:closable="!btnLoading"
:title="$t('button.edit')"

View File

@@ -2,7 +2,9 @@
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAccess } from '@easyflow/access';
import { $t } from '@easyflow/locales';
import { useUserStore } from '@easyflow/stores';
import { ArrowLeft, Plus } from '@element-plus/icons-vue';
import { ElIcon, ElImage } from 'element-plus';
@@ -20,11 +22,34 @@ import KnowledgeSearchConfig from '#/views/ai/documentCollection/KnowledgeSearch
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const { hasAccessByCodes } = useAccess();
const knowledgeId = ref<string>((route.query.id as string) || '');
const activeMenu = ref<string>((route.query.activeMenu as string) || '');
const knowledgeInfo = ref<any>({});
const selectedCategory = ref('');
const canManageKnowledgePermission = computed(() =>
hasAccessByCodes(['/api/v1/documentCollection/save']),
);
const isSuperAdmin = computed(() => {
return (
String(userStore.userInfo?.id || '') === '1' ||
(userStore.userRoles || []).includes('super_admin')
);
});
const canManageCurrentKnowledge = computed(() => {
if (!knowledgeInfo.value?.id || !canManageKnowledgePermission.value) {
return false;
}
return (
isSuperAdmin.value ||
String(userStore.userInfo?.id || '') ===
String(knowledgeInfo.value.createdBy || '')
);
});
const syncNavTitle = (title: string) => {
if (!title) {
@@ -163,6 +188,9 @@ const backDoc = () => {
<div class="description">
{{ knowledgeInfo.description || '' }}
</div>
<div v-if="!canManageCurrentKnowledge" class="permission-tip">
{{ $t('documentCollection.managePermissionHint') }}
</div>
</div>
<div class="doc-top-menu">
<button
@@ -184,7 +212,7 @@ const backDoc = () => {
<div class="doc-header" v-if="!viewDocVisible">
<HeaderSearch
v-if="!isFaqCollection"
:buttons="headerButtons"
:buttons="canManageCurrentKnowledge ? headerButtons : []"
@search="handleSearch"
@button-click="handleButtonClick"
/>
@@ -192,6 +220,7 @@ const backDoc = () => {
<DocumentTable
ref="documentTableRef"
:knowledge-id="knowledgeId"
:manageable="canManageCurrentKnowledge"
@view-doc="viewDoc"
v-if="!viewDocVisible"
/>
@@ -199,24 +228,32 @@ const backDoc = () => {
<ChunkDocumentTable
v-else
:document-id="documentId"
:manageable="canManageCurrentKnowledge"
:default-summary-prompt="knowledgeInfo.summaryPrompt"
/>
</div>
<div v-if="selectedCategory === 'faqList'" class="doc-table">
<FaqTable :knowledge-id="knowledgeId" />
<FaqTable
:knowledge-id="knowledgeId"
:manageable="canManageCurrentKnowledge"
/>
</div>
<!--知识检索-->
<div
v-if="selectedCategory === 'knowledgeSearch'"
class="doc-search-container"
>
<KnowledgeSearchConfig :document-collection-id="knowledgeId" />
<KnowledgeSearchConfig
:document-collection-id="knowledgeId"
:manageable="canManageCurrentKnowledge"
/>
<KnowledgeSearch :knowledge-id="knowledgeId" />
</div>
<!--配置-->
<div v-if="selectedCategory === 'config'">
<DocumentCollectionDataConfig
:detail-data="knowledgeInfo"
:manageable="canManageCurrentKnowledge"
@reload="getKnowledge"
/>
</div>
@@ -306,6 +343,13 @@ const backDoc = () => {
outline-offset: 1px;
}
.permission-tip {
margin-top: 4px;
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-secondary);
}
.doc-table {
background-color: var(--el-bg-color);
}

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>

View File

@@ -1,5 +1,9 @@
<script setup lang="ts">
import type {FormInstance} from 'element-plus';
import type { FormInstance } from 'element-plus';
import { computed, onMounted, ref, watch } from 'vue';
import { InfoFilled } from '@element-plus/icons-vue';
import {
ElButton,
ElForm,
@@ -13,13 +17,9 @@ import {
ElTooltip,
} from 'element-plus';
import {computed, onMounted, ref, watch} from 'vue';
import {InfoFilled} from '@element-plus/icons-vue';
import {api} from '#/api/request';
import { api } from '#/api/request';
import UploadAvatar from '#/components/upload/UploadAvatar.vue';
import {$t} from '#/locales';
import { $t } from '#/locales';
const props = defineProps({
detailData: {
@@ -45,7 +45,10 @@ const props = defineProps({
searchEngineEnable: false,
englishName: '',
}),
required: true,
},
manageable: {
type: Boolean,
default: true,
},
});
@@ -54,7 +57,7 @@ const emit = defineEmits(['reload']);
const normalizeEntity = (raw: any) => {
const options = {
canUpdateEmbeddingModel: true,
...(raw?.options || {}),
...raw?.options,
};
if (options.rerankEnable === undefined || options.rerankEnable === null) {
options.rerankEnable = !!raw?.rerankModelId;
@@ -102,7 +105,7 @@ const vectorStoreConfigPlaceholder = computed(() => {
const getEmbeddingLlmListData = async () => {
try {
const url = `/api/v1/model/list?modelType=embeddingModel`;
const url = `/api/v1/documentCollection/modelList?modelType=embeddingModel`;
const res = await api.get(url, {});
if (res.errorCode === 0) {
embeddingLlmList.value = res.data;
@@ -115,7 +118,9 @@ const getEmbeddingLlmListData = async () => {
const getRerankerLlmListData = async () => {
try {
const res = await api.get('/api/v1/model/list?modelType=rerankModel');
const res = await api.get(
'/api/v1/documentCollection/modelList?modelType=rerankModel',
);
rerankerLlmList.value = res.data;
} catch (error) {
ElMessage.error($t('message.apiError'));
@@ -156,6 +161,10 @@ const rules = ref({
});
async function save() {
if (!props.manageable) {
ElMessage.warning($t('documentCollection.managePermissionHint'));
return;
}
try {
const valid = await saveForm.value?.validate();
if (!valid) return;
@@ -190,9 +199,13 @@ async function save() {
label-width="150px"
ref="saveForm"
:model="entity"
:disabled="!props.manageable"
status-icon
:rules="rules"
>
<div v-if="!props.manageable" class="config-readonly-tip">
{{ $t('documentCollection.managePermissionHint') }}
</div>
<ElFormItem
prop="icon"
:label="$t('documentCollection.icon')"
@@ -371,7 +384,7 @@ async function save() {
type="primary"
@click="save"
:loading="btnLoading"
:disabled="btnLoading"
:disabled="btnLoading || !props.manageable"
>
{{ $t('button.save') }}
</ElButton>
@@ -385,4 +398,11 @@ async function save() {
height: 100%;
overflow: auto;
}
.config-readonly-tip {
margin-bottom: 16px;
font-size: 12px;
line-height: 1.6;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { EasyFlowFormModal } from '@easyflow/common-ui';
@@ -29,7 +29,7 @@ const rerankerLlmList = ref<any>([]);
const getEmbeddingLlmListData = async () => {
try {
const url = `/api/v1/model/list?modelType=embeddingModel`;
const url = `/api/v1/documentCollection/modelList?modelType=embeddingModel`;
const res = await api.get(url, {});
if (res.errorCode === 0) {
embeddingLlmList.value = res.data;
@@ -42,7 +42,9 @@ const getEmbeddingLlmListData = async () => {
const getRerankerLlmListData = async () => {
try {
const res = await api.get('/api/v1/model/list?modelType=rerankModel');
const res = await api.get(
'/api/v1/documentCollection/modelList?modelType=rerankModel',
);
rerankerLlmList.value = res.data;
} catch (error) {
ElMessage.error($t('message.apiError'));
@@ -87,8 +89,9 @@ const defaultEntity = {
rerankEnable: false,
},
rerankModelId: '',
searchEngineEnable: '',
searchEngineEnable: false,
englishName: '',
visibilityScope: 'PRIVATE',
};
const normalizeEntity = (raw: any = {}) => {
const options = {
@@ -135,6 +138,20 @@ const rules = ref({
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
});
const visibilityScopeOptions = computed(() => [
{
label: $t('documentCollection.visibilityScopePrivate'),
value: 'PRIVATE',
},
{
label: $t('documentCollection.visibilityScopeDept'),
value: 'DEPT',
},
{
label: $t('documentCollection.visibilityScopePublic'),
value: 'PUBLIC',
},
]);
const collectionTypeList = [
{
label: $t('documentCollection.collectionTypeDocument'),
@@ -257,6 +274,19 @@ defineExpose({
dict-code="aiDocumentCollectionCategory"
/>
</ElFormItem>
<ElFormItem
prop="visibilityScope"
:label="$t('documentCollection.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('documentCollection.alias')">
<ElInput v-model.trim="entity.alias" />
</ElFormItem>

View File

@@ -2,6 +2,7 @@
import { ref } from 'vue';
import { $t } from '@easyflow/locales';
import { downloadFileFromBlob } from '@easyflow/utils';
import { Delete, Download, MoreFilled } from '@element-plus/icons-vue';
import {
@@ -25,6 +26,10 @@ const props = defineProps({
required: true,
type: String,
},
manageable: {
type: Boolean,
default: true,
},
});
const emits = defineEmits(['viewDoc']);
defineExpose({
@@ -38,10 +43,20 @@ const pageDataRef = ref();
const handleView = (row: any) => {
emits('viewDoc', row.id);
};
const handleDownload = (row: any) => {
window.open(row.documentPath, '_blank');
const handleDownload = async (row: any) => {
const blob = await api.download(
`/api/v1/document/download?documentId=${row.id}`,
);
downloadFileFromBlob({
fileName: row.title || 'document',
source: blob,
});
};
const handleDelete = (row: any) => {
if (!props.manageable) {
ElMessage.warning($t('documentCollection.managePermissionHint'));
return;
}
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
confirmButtonText: $t('button.confirm'),
cancelButtonText: $t('button.cancel'),
@@ -122,7 +137,10 @@ const handleDelete = (row: any) => {
{{ $t('button.download') }}
</ElButton>
</ElDropdownItem>
<ElDropdownItem @click="handleDelete(row)">
<ElDropdownItem
v-if="props.manageable"
@click="handleDelete(row)"
>
<ElButton link :icon="Delete" type="danger">
{{ $t('button.delete') }}
</ElButton>

View File

@@ -23,9 +23,9 @@ import {
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElInput,
ElMessage,
ElMessageBox,
ElInput,
ElTable,
ElTableColumn,
ElTree,
@@ -43,6 +43,10 @@ const props = defineProps({
type: String,
required: true,
},
manageable: {
type: Boolean,
default: true,
},
});
const pageDataRef = ref();
@@ -143,6 +147,10 @@ const handleResetSearch = () => {
};
const openAddDialog = () => {
if (!props.manageable) {
ElMessage.warning($t('documentCollection.managePermissionHint'));
return;
}
editData.value = {
collectionId: props.knowledgeId,
categoryId:
@@ -189,12 +197,17 @@ const exportFaqExcel = async () => {
}
};
const handleImportSuccess = () => {
const handleImportSuccess = async () => {
await reloadCategoryTree();
refreshList();
};
const handleMoreActionCommand = (command: string) => {
if (command === 'import') {
if (!props.manageable) {
ElMessage.warning($t('documentCollection.managePermissionHint'));
return;
}
importDialogVisible.value = true;
return;
}
@@ -208,6 +221,10 @@ const handleMoreActionCommand = (command: string) => {
};
const openEditDialog = (row: any) => {
if (!props.manageable) {
ElMessage.warning($t('documentCollection.managePermissionHint'));
return;
}
editData.value = {
id: row.id,
collectionId: row.collectionId,
@@ -223,6 +240,10 @@ const openEditDialog = (row: any) => {
};
const saveFaq = async (payload: any) => {
if (!props.manageable) {
ElMessage.warning($t('documentCollection.managePermissionHint'));
return;
}
const url = payload.id ? '/api/v1/faqItem/update' : '/api/v1/faqItem/save';
const res = await api.post(url, payload);
if (res.errorCode === 0) {
@@ -237,6 +258,10 @@ const saveFaq = async (payload: any) => {
};
const removeFaq = (row: any) => {
if (!props.manageable) {
ElMessage.warning($t('documentCollection.managePermissionHint'));
return;
}
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
confirmButtonText: $t('button.confirm'),
cancelButtonText: $t('button.cancel'),
@@ -259,6 +284,10 @@ const handleCategoryClick = (data: any) => {
};
const openAddRootCategory = () => {
if (!props.manageable) {
ElMessage.warning($t('documentCollection.managePermissionHint'));
return;
}
categoryDialogTitle.value = $t('documentCollection.faq.addCategory');
categoryDialogDisableParent.value = false;
categoryEditData.value = {
@@ -271,6 +300,10 @@ const openAddRootCategory = () => {
};
const openAddSiblingCategory = (node: any) => {
if (!props.manageable) {
ElMessage.warning($t('documentCollection.managePermissionHint'));
return;
}
categoryDialogTitle.value = $t('documentCollection.faq.addSiblingCategory');
categoryDialogDisableParent.value = false;
categoryEditData.value = {
@@ -286,6 +319,10 @@ const openAddSiblingCategory = (node: any) => {
};
const openAddChildCategory = (node: any) => {
if (!props.manageable) {
ElMessage.warning($t('documentCollection.managePermissionHint'));
return;
}
if (node.isDefault) {
ElMessage.warning(
$t('documentCollection.faq.defaultCategoryChildForbidden'),
@@ -308,6 +345,10 @@ const openAddChildCategory = (node: any) => {
};
const openEditCategory = (node: any) => {
if (!props.manageable) {
ElMessage.warning($t('documentCollection.managePermissionHint'));
return;
}
categoryDialogTitle.value = $t('documentCollection.faq.editCategory');
categoryDialogDisableParent.value = !!node.isDefault;
categoryEditData.value = {
@@ -324,6 +365,10 @@ const openEditCategory = (node: any) => {
};
const removeCategory = (node: any) => {
if (!props.manageable) {
ElMessage.warning($t('documentCollection.managePermissionHint'));
return;
}
if (node.isDefault) {
ElMessage.warning(
$t('documentCollection.faq.defaultCategoryDeleteForbidden'),
@@ -612,6 +657,10 @@ const demoteCategory = async (node: any) => {
};
const saveCategory = async (payload: any) => {
if (!props.manageable) {
ElMessage.warning($t('documentCollection.managePermissionHint'));
return;
}
const url = payload.id
? '/api/v1/faqCategory/update'
: '/api/v1/faqCategory/save';
@@ -692,6 +741,7 @@ onMounted(() => {
<div class="faq-category-header">
<span>{{ $t('documentCollection.faq.categoryTree') }}</span>
<ElButton
v-if="props.manageable"
link
type="primary"
:icon="Plus"
@@ -719,7 +769,7 @@ onMounted(() => {
data.categoryName
}}</span>
<div
v-if="!data.isVirtual && !data.isDefault"
v-if="props.manageable && !data.isVirtual && !data.isDefault"
class="faq-category-node-actions"
@click.stop
>
@@ -770,7 +820,10 @@ onMounted(() => {
>
{{ $t('documentCollection.faq.addChildCategory') }}
</ElDropdownItem>
<ElDropdownItem :icon="Edit" @click="openEditCategory(data)">
<ElDropdownItem
:icon="Edit"
@click="openEditCategory(data)"
>
{{ $t('button.edit') }}
</ElDropdownItem>
<ElDropdownItem
@@ -791,6 +844,9 @@ onMounted(() => {
<div class="faq-content-pane">
<div class="faq-header">
<div v-if="!props.manageable" class="faq-readonly-tip">
{{ $t('documentCollection.managePermissionHint') }}
</div>
<div class="faq-toolbar">
<div class="faq-search-actions">
<ElInput
@@ -809,19 +865,25 @@ onMounted(() => {
</div>
<div class="faq-primary-actions">
<ElButton type="primary" :icon="Plus" @click="openAddDialog">
<ElButton
v-if="props.manageable"
type="primary"
:icon="Plus"
@click="openAddDialog"
>
{{ $t('button.add') }}
</ElButton>
<ElDropdown
trigger="click"
@command="handleMoreActionCommand"
>
<ElDropdown trigger="click" @command="handleMoreActionCommand">
<ElButton :icon="MoreFilled">
{{ $t('documentCollection.faq.import.moreActions') }}
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem command="import" :icon="Upload">
<ElDropdownItem
v-if="props.manageable"
command="import"
:icon="Upload"
>
{{ $t('button.import') }}
</ElDropdownItem>
<ElDropdownItem
@@ -871,6 +933,7 @@ onMounted(() => {
show-overflow-tooltip
/>
<ElTableColumn
v-if="props.manageable"
:label="$t('common.handle')"
width="170"
align="right"
@@ -1002,6 +1065,13 @@ onMounted(() => {
margin-bottom: 12px;
}
.faq-readonly-tip {
margin-bottom: 10px;
font-size: 12px;
line-height: 1.6;
color: var(--el-text-color-secondary);
}
.faq-toolbar {
display: flex;
align-items: center;
@@ -1062,8 +1132,11 @@ onMounted(() => {
}
:deep(
.faq-category-tree > .el-tree-node > .el-tree-node__content > .el-tree-node__expand-icon
) {
.faq-category-tree
> .el-tree-node
> .el-tree-node__content
> .el-tree-node__expand-icon
) {
display: none;
}
@@ -1077,7 +1150,9 @@ onMounted(() => {
background-color: var(--el-fill-color-light);
}
:deep(.faq-category-tree .el-tree-node__content:hover .faq-category-node-actions) {
:deep(
.faq-category-tree .el-tree-node__content:hover .faq-category-node-actions
) {
opacity: 1;
}
@@ -1086,7 +1161,9 @@ onMounted(() => {
color: hsl(var(--primary));
}
:deep(.el-tree-node.is-current > .el-tree-node__content .faq-category-node-actions) {
:deep(
.el-tree-node.is-current > .el-tree-node__content .faq-category-node-actions
) {
opacity: 1;
}

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import {onMounted, reactive, ref} from 'vue';
import { onMounted, reactive, ref } from 'vue';
import {$t} from '@easyflow/locales';
import { $t } from '@easyflow/locales';
import {InfoFilled} from '@element-plus/icons-vue';
import { InfoFilled } from '@element-plus/icons-vue';
import {
ElButton,
ElForm,
@@ -16,13 +16,17 @@ import {
ElTooltip,
} from 'element-plus';
import {api} from '#/api/request';
import { api } from '#/api/request';
const props = defineProps({
documentCollectionId: {
type: String,
required: true,
},
manageable: {
type: Boolean,
default: true,
},
});
onMounted(() => {
@@ -55,6 +59,10 @@ const searchConfig = reactive({
});
const submitConfig = () => {
if (!props.manageable) {
ElMessage.warning($t('documentCollection.managePermissionHint'));
return;
}
const submitData = {
id: props.documentCollectionId,
options: {
@@ -89,6 +97,9 @@ const searchEngineOptions = [
},
];
const handleSearchEngineEnableChange = () => {
if (!props.manageable) {
return;
}
api.post('/api/v1/documentCollection/update', {
id: props.documentCollectionId,
searchEngineEnable: searchEngineEnable.value,
@@ -100,11 +111,15 @@ const handleSearchEngineEnableChange = () => {
<div class="search-config-sidebar">
<div class="config-header">
<h3>{{ $t('documentCollectionSearch.title') }}</h3>
<div v-if="!props.manageable" class="config-readonly-tip">
{{ $t('documentCollection.managePermissionHint') }}
</div>
</div>
<ElForm
class="config-form"
:model="searchConfig"
:disabled="!props.manageable"
label-width="100%"
size="small"
>
@@ -227,7 +242,12 @@ const handleSearchEngineEnableChange = () => {
</ElForm>
<div class="config-footer">
<ElButton type="primary" @click="submitConfig" class="submit-btn">
<ElButton
type="primary"
@click="submitConfig"
class="submit-btn"
:disabled="!props.manageable"
>
{{ $t('documentCollectionSearch.button.save') }}
</ElButton>
</div>
@@ -256,6 +276,13 @@ const handleSearchEngineEnableChange = () => {
font-weight: 600;
}
.config-readonly-tip {
margin-top: 8px;
font-size: 12px;
line-height: 1.6;
color: var(--el-text-color-secondary);
}
.config-form {
margin-bottom: 24px;
}

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>