Files
EasyFlow/easyflow-ui-admin/app/src/views/ai/documentCollection/Document.vue
陈子默 51dbfd41b6 feat: 完善知识库批量导入与公共 API
- 新增批量异步导入、状态查询、失败重试与中断恢复链路

- 拆分知识库读取、导入、维护权限并完善 Public API 契约

- 补充数据库迁移、管理端交互、接口说明与相关测试
2026-08-03 11:14:41 +08:00

528 lines
14 KiB
Vue

<script setup lang="ts">
import { computed, defineAsyncComponent, nextTick, 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 { ElButton, ElIcon, ElImage, ElMessage } from 'element-plus';
import { api } from '#/api/request';
import bookIcon from '#/assets/ai/knowledge/book.svg';
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
import { createLazyComponentController } from '#/utils/lazy-component';
import DocumentImportBatchStatus from '#/views/ai/documentCollection/DocumentImportBatchStatus.vue';
const ChunkDocumentTable = defineAsyncComponent(
() => import('#/views/ai/documentCollection/ChunkDocumentTable.vue'),
);
const DocumentCollectionDataConfig = defineAsyncComponent(
() =>
import('#/views/ai/documentCollection/DocumentCollectionDataConfig.vue'),
);
const DocumentTable = defineAsyncComponent(
() => import('#/views/ai/documentCollection/DocumentTable.vue'),
);
const FaqTable = defineAsyncComponent(
() => import('#/views/ai/documentCollection/FaqTable.vue'),
);
const KnowledgeSearch = defineAsyncComponent(
() => import('#/views/ai/documentCollection/KnowledgeSearch.vue'),
);
const KnowledgeShareManagement = defineAsyncComponent(
() => import('#/views/ai/documentCollection/KnowledgeShareManagement.vue'),
);
const SegmenterDoc = defineAsyncComponent(
() => import('#/views/ai/documentCollection/SegmenterDoc.vue'),
);
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const { hasAccessByCodes } = useAccess();
interface ImportKnowledgeDocFileInstance {
openDialog: () => void;
}
const {
component: ImportKnowledgeDocFileComponent,
componentRef: importDocModalRef,
ensureInstance: ensureImportKnowledgeDocFile,
loading: importDialogLoading,
} = createLazyComponentController<ImportKnowledgeDocFileInstance>(
() => import('#/views/ai/documentCollection/ImportKnowledgeDocFile.vue'),
);
async function openImportDialog() {
try {
const modal = await ensureImportKnowledgeDocFile();
modal.openDialog();
} catch (error) {
console.error('Failed to load document import modal:', error);
ElMessage.error($t('message.getDataError'));
}
}
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) {
return;
}
const query = route.query as Record<string, any>;
const navTitle = Array.isArray(query.navTitle)
? query.navTitle[0]
: query.navTitle;
if (typeof navTitle === 'string' && navTitle.trim()) {
return;
}
router.replace({
path: route.path,
query: {
...query,
pageKey: query.pageKey || '/ai/documentCollection',
navTitle: title,
},
});
};
const resolveDefaultMenu = (collectionType: string, menuKey: string) => {
const faqMenus = new Set(['config', 'faqList', 'knowledgeSearch', 'share']);
const documentMenus = new Set([
'config',
'documentList',
'knowledgeSearch',
'share',
]);
const fallbackMenu = collectionType === 'FAQ' ? 'faqList' : 'documentList';
if (!menuKey) {
return fallbackMenu;
}
const validMenuSet = collectionType === 'FAQ' ? faqMenus : documentMenus;
return validMenuSet.has(menuKey) ? menuKey : fallbackMenu;
};
const getKnowledge = () => {
api
.get('/api/v1/documentCollection/detail', {
params: { id: knowledgeId.value },
})
.then((res) => {
if (res.errorCode === 0) {
knowledgeInfo.value = res.data;
syncNavTitle(res.data?.title || '');
const initialMenu = resolveDefaultMenu(
res.data.collectionType || 'DOCUMENT',
activeMenu.value,
);
selectedCategory.value = initialMenu;
}
});
};
onMounted(() => {
getKnowledge();
});
const back = () => {
router.push({ path: '/ai/documentCollection' });
};
const isFaqCollection = computed(
() => knowledgeInfo.value.collectionType === 'FAQ',
);
const categoryData = computed(() => {
if (isFaqCollection.value) {
return [
{ key: 'faqList', name: $t('documentCollection.faq.faqList') },
{
key: 'knowledgeSearch',
name: $t('documentCollection.knowledgeRetrieval'),
},
{ key: 'config', name: $t('documentCollection.config') },
{ key: 'share', name: '分享' },
];
}
return [
{ key: 'documentList', name: $t('documentCollection.documentList') },
{
key: 'knowledgeSearch',
name: $t('documentCollection.knowledgeRetrieval'),
},
{ key: 'config', name: $t('documentCollection.config') },
{ key: 'share', name: '分享' },
];
});
const headerButtons = [
{
key: 'importFile',
text: $t('button.importFile'),
icon: Plus,
type: 'primary',
data: { action: 'importFile' },
},
];
const panelMode = ref<'chunk' | 'list' | 'process'>('list');
const documentTableRef = ref();
const batchStatusRefreshKey = ref(0);
const documentTitle = ref('');
const handleSearch = (searchParams: string) => {
documentTableRef.value?.search?.(searchParams);
};
const handleButtonClick = (event: any) => {
// 根据按钮 key 执行不同操作
switch (event.key) {
case 'back': {
router.push({ path: '/ai/knowledge' });
break;
}
case 'importFile': {
void openImportDialog();
break;
}
}
};
const handleCategoryClick = (menuKey: string) => {
selectedCategory.value = menuKey;
panelMode.value = 'list';
};
const documentId = ref('');
// 子组件传递事件,显示查看文档详情
const viewDoc = (docId: string) => {
panelMode.value = 'chunk';
documentId.value = docId;
};
const continueProcess = (doc: any) => {
panelMode.value = 'process';
documentId.value = String(doc?.id || '');
documentTitle.value = doc?.title || '';
};
const backDoc = async () => {
if (panelMode.value !== 'list') {
panelMode.value = 'list';
}
documentTitle.value = '';
await nextTick();
documentTableRef.value?.reload?.();
batchStatusRefreshKey.value += 1;
};
</script>
<template>
<div v-loading="importDialogLoading" class="document-container">
<div class="doc-header-container">
<div class="doc-knowledge-container">
<div @click="back()" style="cursor: pointer">
<ElIcon><ArrowLeft /></ElIcon>
</div>
<div>
<ElImage :src="bookIcon" style="width: 36px; height: 36px" />
</div>
<div class="knowledge-info-container">
<div class="title">{{ knowledgeInfo.title || '' }}</div>
<div class="description">
{{ knowledgeInfo.description || '' }}
</div>
<div v-if="!canManageCurrentKnowledge" class="permission-tip">
{{ $t('documentCollection.managePermissionHint') }}
</div>
</div>
<div class="doc-top-menu">
<button
v-for="item in categoryData"
:key="item.key"
class="doc-menu-item"
:class="{ active: selectedCategory === item.key }"
@click="handleCategoryClick(item.key)"
>
{{ item.name }}
</button>
</div>
</div>
<div class="doc-content">
<div
class="doc-table-content menu-container border border-[var(--el-border-color)]"
:class="{ 'doc-table-content--process': panelMode === 'process' }"
>
<div v-if="selectedCategory === 'documentList'" class="doc-table">
<div class="doc-header" v-if="panelMode === 'list'">
<HeaderSearch
v-if="!isFaqCollection"
:buttons="canManageCurrentKnowledge ? headerButtons : []"
@search="handleSearch"
@button-click="handleButtonClick"
>
<template #middle>
<DocumentImportBatchStatus
:knowledge-id="knowledgeId"
:manageable="canManageCurrentKnowledge"
:refresh-key="batchStatusRefreshKey"
@continued="backDoc"
/>
</template>
</HeaderSearch>
</div>
<div v-if="panelMode === 'chunk'" class="doc-sub-back">
<ElButton @click="backDoc">
{{ $t('button.back') }}
</ElButton>
</div>
<DocumentTable
v-if="panelMode === 'list'"
ref="documentTableRef"
:knowledge-id="knowledgeId"
:permissions="{
canCreateContent: canManageCurrentKnowledge,
canDeleteContent: canManageCurrentKnowledge,
canDownloadContent: true,
}"
@continue-process="continueProcess"
@view-doc="viewDoc"
/>
<ChunkDocumentTable
v-else-if="panelMode === 'chunk'"
:document-id="documentId"
:manageable="canManageCurrentKnowledge"
:default-summary-prompt="knowledgeInfo.summaryPrompt"
/>
<SegmenterDoc
v-else-if="panelMode === 'process'"
:knowledge-id="String(knowledgeId)"
:document-id="documentId"
:document-title="documentTitle"
@cancel="backDoc"
@started="backDoc"
/>
</div>
<div v-if="selectedCategory === 'faqList'" class="doc-table">
<FaqTable
:knowledge-id="knowledgeId"
:manageable="canManageCurrentKnowledge"
/>
</div>
<!--知识检索-->
<div
v-if="selectedCategory === 'knowledgeSearch'"
class="doc-search-container"
>
<KnowledgeSearch :knowledge-id="knowledgeId" :show-config="true" />
</div>
<!--配置-->
<div v-if="selectedCategory === 'config'">
<DocumentCollectionDataConfig
:detail-data="knowledgeInfo"
:manageable="canManageCurrentKnowledge"
@reload="getKnowledge"
/>
</div>
<div v-if="selectedCategory === 'share'">
<KnowledgeShareManagement
:knowledge-id="String(knowledgeId)"
:collection-type="knowledgeInfo.collectionType || 'DOCUMENT'"
:manageable="canManageCurrentKnowledge"
/>
</div>
</div>
</div>
</div>
<component
:is="ImportKnowledgeDocFileComponent"
v-if="ImportKnowledgeDocFileComponent"
ref="importDocModalRef"
enable-bulk-auto
:knowledge-id-prop="String(knowledgeId)"
@imported="backDoc"
/>
</div>
</template>
<style scoped>
.document-container {
display: flex;
width: 100%;
height: 100%;
padding: 24px 24px 30px;
}
.doc-container {
box-sizing: border-box;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
.doc-table-content {
box-sizing: border-box;
flex: 1;
width: 100%;
padding: 20px 14px 0;
background-color: var(--el-bg-color);
border-radius: 8px;
}
.doc-table-content--process {
padding: 18px 20px 0;
border-color: rgb(15 23 42 / 6%);
border-radius: 18px;
}
.doc-header {
width: 100%;
padding-bottom: 21px;
margin: 0 auto;
}
.doc-header :deep(.search-middle) {
flex: 1 1 360px;
}
.doc-content {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
height: 100%;
}
.doc-top-menu {
display: flex;
flex: 1;
flex-wrap: wrap;
gap: 6px;
align-items: center;
width: auto;
min-height: 32px;
padding: 0;
background-color: transparent;
border-radius: 0;
}
.doc-menu-item {
padding: 7px 14px;
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-secondary);
cursor: pointer;
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
transition:
background-color 0.2s,
color 0.2s,
border-color 0.2s,
box-shadow 0.2s;
}
.doc-menu-item:hover {
color: var(--el-text-color-primary);
background-color: var(--el-fill-color-light);
}
.doc-menu-item.active {
font-weight: 600;
color: var(--el-color-primary);
background-color: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-7);
box-shadow: 0 2px 10px rgb(64 158 255 / 16%);
}
.doc-menu-item:focus-visible {
outline: 2px solid var(--el-color-primary-light-5);
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);
}
.doc-sub-back {
padding: 0 6px 16px;
}
.doc-header-container {
display: flex;
flex-direction: column;
width: 100%;
}
.doc-knowledge-container {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
margin-bottom: 20px;
}
.knowledge-info-container {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 160px;
}
.title {
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: 24px;
text-align: left;
text-transform: none;
}
.description {
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px;
color: #75808d;
text-align: left;
text-transform: none;
}
.doc-search-container {
display: flex;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.menu-container {
flex: 1;
}
</style>