- 新增批量异步导入、状态查询、失败重试与中断恢复链路 - 拆分知识库读取、导入、维护权限并完善 Public API 契约 - 补充数据库迁移、管理端交互、接口说明与相关测试
528 lines
14 KiB
Vue
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>
|