feat: 重构知识库文档导入任务化流程
- 新增上传建单、异步解析、分块处理与异步向量化闭环 - 收口分享页权限、完成态检索过滤与 SSE 局部状态刷新
This commit is contained in:
@@ -78,6 +78,25 @@ const handleCurrentChange = (newPage: number) => {
|
||||
pageInfo.pageNumber = newPage;
|
||||
};
|
||||
|
||||
const patchRowById = (
|
||||
id: number | string,
|
||||
patch: Record<string, any>,
|
||||
): boolean => {
|
||||
const rowIndex = pageList.value.findIndex(
|
||||
(item: Record<string, any>) => String(item?.id ?? '') === String(id ?? ''),
|
||||
);
|
||||
if (rowIndex === -1) {
|
||||
return false;
|
||||
}
|
||||
const nextPageList = [...pageList.value];
|
||||
nextPageList[rowIndex] = {
|
||||
...nextPageList[rowIndex],
|
||||
...patch,
|
||||
};
|
||||
pageList.value = nextPageList;
|
||||
return true;
|
||||
};
|
||||
|
||||
// 暴露给父组件的方法 (替代 useImperativeHandle)
|
||||
const setQuery = (newQueryParams: Record<string, any>) => {
|
||||
pageInfo.pageNumber = 1;
|
||||
@@ -89,6 +108,7 @@ const setQuery = (newQueryParams: Record<string, any>) => {
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
reload: getPageList,
|
||||
patchRowById,
|
||||
setQuery,
|
||||
});
|
||||
|
||||
|
||||
@@ -67,8 +67,13 @@ const triggerFileSelect = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const clearFiles = () => {
|
||||
uploadRef.value?.clearFiles?.();
|
||||
};
|
||||
|
||||
// 对外暴露方法(父组件可通过ref调用)
|
||||
defineExpose({
|
||||
clearFiles,
|
||||
triggerFileSelect,
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -48,5 +48,9 @@
|
||||
"more": "Mode",
|
||||
"submitDeleteApproval": "Submit Delete Approval",
|
||||
"submitPublishApproval": "Submit Publish Approval",
|
||||
"viewSegmentation": "ViewSegmentation"
|
||||
"viewSegmentation": "View Segments",
|
||||
"continueProcess": "Continue",
|
||||
"startIndex": "Start Indexing",
|
||||
"retryParse": "Retry Parse",
|
||||
"retryIndex": "Retry Index"
|
||||
}
|
||||
|
||||
@@ -32,9 +32,10 @@
|
||||
"rerankLlmId": "RerankLlm",
|
||||
"searchEngineEnable": "SearchEngineEnable",
|
||||
"englishName": "EnglishName",
|
||||
"documentType": "DocumentType",
|
||||
"fileName": "fileName",
|
||||
"knowledgeCount": "Number of knowledge items",
|
||||
"chunkCount": "Chunks",
|
||||
"processStatus": "Status",
|
||||
"progress": "Progress",
|
||||
"publishStatusDraft": "Draft",
|
||||
"publishStatusPublishPending": "Publish Pending",
|
||||
"publishStatusPublished": "Published",
|
||||
@@ -77,17 +78,32 @@
|
||||
"fileName": "File Name",
|
||||
"progressUpload": "Progress of file upload",
|
||||
"fileSize": "File size",
|
||||
"uploadCreateTip": "After upload, the document appears in the list first and is parsed asynchronously. Continue with chunking after parsing finishes.",
|
||||
"analysisTip": "The system analyzes multilingual structure first and recommends a splitting strategy. You can still adjust each file manually.",
|
||||
"manualStrategyTip": "The preview refreshes automatically when the chunking strategy changes. Start indexing after it looks right.",
|
||||
"confidence": "Confidence",
|
||||
"recommendReason": "Reasons",
|
||||
"candidateStrategies": "Candidates",
|
||||
"strategySelection": "Strategy",
|
||||
"previewTip": "The preview result is the final import basis. Confirm it before committing.",
|
||||
"previewPaneTitle": "Chunk Preview",
|
||||
"previewEmpty": "No preview data",
|
||||
"previewReady": "Preview ready",
|
||||
"previewRefreshing": "Refreshing preview",
|
||||
"previewRequestFailed": "Failed to refresh preview. Please try again.",
|
||||
"warningCount": "Warnings",
|
||||
"chunkCount": "Chunks",
|
||||
"lockedState": "Locked",
|
||||
"normalizedDocumentTitle": "Source Text",
|
||||
"normalizedDocumentTip": "Shows the normalized source text so each chunk can be checked in context.",
|
||||
"resultEmpty": "No import result",
|
||||
"importFailed": "Import failed"
|
||||
"importFailed": "Import failed",
|
||||
"createSuccess": "Documents added to the list",
|
||||
"partialCreateSuccess": "Only some documents were added successfully",
|
||||
"indexQueued": "Indexing started",
|
||||
"previewAction": "Generate Preview",
|
||||
"workbenchEyebrow": "Processing Workspace",
|
||||
"workbenchTitle": "Document Processing"
|
||||
},
|
||||
"splitterDoc": {
|
||||
"fileType": "FileType",
|
||||
@@ -113,6 +129,16 @@
|
||||
"uploading": "Parsing in progress",
|
||||
"importSuccess": "ImportSuccess"
|
||||
},
|
||||
"taskStatus": {
|
||||
"UPLOADED": "Uploaded",
|
||||
"PARSING": "Parsing",
|
||||
"PARSE_FAILED": "Parse Failed",
|
||||
"READY_FOR_SEGMENT": "Ready for Chunking",
|
||||
"READY_FOR_INDEX": "Ready for Indexing",
|
||||
"INDEXING": "Indexing",
|
||||
"INDEX_FAILED": "Index Failed",
|
||||
"COMPLETED": "Completed"
|
||||
},
|
||||
"documentManagement": "Document management",
|
||||
"actions": {
|
||||
"knowledge": "Knowledge",
|
||||
@@ -188,5 +214,6 @@
|
||||
"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",
|
||||
"managePermissionHint": "Only the creator or super admin can modify this knowledge base"
|
||||
"managePermissionHint": "Only the creator or super admin can modify this knowledge base",
|
||||
"processingDeleteBlocked": "Documents in progress cannot be deleted"
|
||||
}
|
||||
|
||||
@@ -48,5 +48,9 @@
|
||||
"more": "更多",
|
||||
"submitDeleteApproval": "提交删除审批",
|
||||
"submitPublishApproval": "提交发布审批",
|
||||
"viewSegmentation": "查看分段"
|
||||
"viewSegmentation": "查看分段",
|
||||
"continueProcess": "继续处理",
|
||||
"startIndex": "开始向量化",
|
||||
"retryParse": "重试解析",
|
||||
"retryIndex": "重试向量化"
|
||||
}
|
||||
|
||||
@@ -32,9 +32,10 @@
|
||||
"rerankLlmId": "重排模型",
|
||||
"searchEngineEnable": "是否启用搜索引擎",
|
||||
"englishName": "英文名称",
|
||||
"documentType": "文件类型",
|
||||
"fileName": "文件名",
|
||||
"knowledgeCount": "知识条数",
|
||||
"chunkCount": "分块数",
|
||||
"processStatus": "处理状态",
|
||||
"progress": "处理进度",
|
||||
"publishStatusDraft": "草稿",
|
||||
"publishStatusPublishPending": "发布审批中",
|
||||
"publishStatusPublished": "已发布",
|
||||
@@ -77,17 +78,32 @@
|
||||
"fileName": "文件名称",
|
||||
"progressUpload": "文件上传进度",
|
||||
"fileSize": "文件大小",
|
||||
"uploadCreateTip": "上传完成后,文档会先进入列表并异步解析,解析完成后再继续分块和向量化。",
|
||||
"analysisTip": "系统会先基于文档结构做中英文规则分析,再推荐拆分策略,你也可以逐个文件手动调整。",
|
||||
"manualStrategyTip": "调整分块策略后会自动刷新预览,确认效果后再启动向量化。",
|
||||
"confidence": "置信度",
|
||||
"recommendReason": "推荐理由",
|
||||
"candidateStrategies": "备选策略",
|
||||
"strategySelection": "拆分策略",
|
||||
"previewTip": "预览结果就是最终入库依据,确认无误后再执行导入。",
|
||||
"previewPaneTitle": "分块预览",
|
||||
"previewEmpty": "暂无可预览内容",
|
||||
"previewReady": "预览已更新",
|
||||
"previewRefreshing": "正在更新预览",
|
||||
"previewRequestFailed": "预览生成失败,请稍后重试",
|
||||
"warningCount": "警告数",
|
||||
"chunkCount": "分块数",
|
||||
"lockedState": "已锁定",
|
||||
"normalizedDocumentTitle": "原文",
|
||||
"normalizedDocumentTip": "展示解析后的标准化文本,便于核对每个分块落点。",
|
||||
"resultEmpty": "暂无导入结果",
|
||||
"importFailed": "导入失败"
|
||||
"importFailed": "导入失败",
|
||||
"createSuccess": "文档已加入列表",
|
||||
"partialCreateSuccess": "已加入部分文档,请检查失败项",
|
||||
"indexQueued": "已开始向量化",
|
||||
"previewAction": "生成预览",
|
||||
"workbenchEyebrow": "处理工作台",
|
||||
"workbenchTitle": "文档处理"
|
||||
},
|
||||
"splitterDoc": {
|
||||
"fileType": "文件类型",
|
||||
@@ -113,6 +129,16 @@
|
||||
"uploading": "解析中",
|
||||
"importSuccess": "导入成功"
|
||||
},
|
||||
"taskStatus": {
|
||||
"UPLOADED": "已上传",
|
||||
"PARSING": "解析中",
|
||||
"PARSE_FAILED": "解析失败",
|
||||
"READY_FOR_SEGMENT": "待分块",
|
||||
"READY_FOR_INDEX": "待向量化",
|
||||
"INDEXING": "向量化中",
|
||||
"INDEX_FAILED": "向量化失败",
|
||||
"COMPLETED": "已完成"
|
||||
},
|
||||
"documentManagement": "文档管理",
|
||||
"actions": {
|
||||
"knowledge": "知识",
|
||||
@@ -188,5 +214,6 @@
|
||||
"vectorEmbedModelTips": "成功向量数据之后不允许修改向量模型",
|
||||
"dimensionOfVectorModelTips": "成功向量数据之后不允许修改向量模型维度",
|
||||
"dimensionOfVectorModel": "向量模型维度",
|
||||
"managePermissionHint": "仅创建者或超级管理员可修改当前知识库"
|
||||
"managePermissionHint": "仅创建者或超级管理员可修改当前知识库",
|
||||
"processingDeleteBlocked": "文档处理中,暂不允许删除"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, nextTick, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { useAccess } from '@easyflow/access';
|
||||
@@ -7,7 +7,7 @@ import { $t } from '@easyflow/locales';
|
||||
import { useUserStore } from '@easyflow/stores';
|
||||
|
||||
import { ArrowLeft, Plus } from '@element-plus/icons-vue';
|
||||
import { ElIcon, ElImage } from 'element-plus';
|
||||
import { ElButton, ElIcon, ElImage } from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import bookIcon from '#/assets/ai/knowledge/book.svg';
|
||||
@@ -18,8 +18,8 @@ import DocumentTable from '#/views/ai/documentCollection/DocumentTable.vue';
|
||||
import FaqTable from '#/views/ai/documentCollection/FaqTable.vue';
|
||||
import ImportKnowledgeDocFile from '#/views/ai/documentCollection/ImportKnowledgeDocFile.vue';
|
||||
import KnowledgeSearch from '#/views/ai/documentCollection/KnowledgeSearch.vue';
|
||||
import KnowledgeSearchConfig from '#/views/ai/documentCollection/KnowledgeSearchConfig.vue';
|
||||
import KnowledgeShareManagement from '#/views/ai/documentCollection/KnowledgeShareManagement.vue';
|
||||
import SegmenterDoc from '#/views/ai/documentCollection/SegmenterDoc.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -147,8 +147,10 @@ const headerButtons = [
|
||||
data: { action: 'importFile' },
|
||||
},
|
||||
];
|
||||
const isImportFileVisible = ref(false);
|
||||
const panelMode = ref<'chunk' | 'list' | 'process'>('list');
|
||||
const documentTableRef = ref();
|
||||
const importDocModalRef = ref<InstanceType<typeof ImportKnowledgeDocFile>>();
|
||||
const documentTitle = ref('');
|
||||
const handleSearch = (searchParams: string) => {
|
||||
documentTableRef.value.search(searchParams);
|
||||
};
|
||||
@@ -160,30 +162,39 @@ const handleButtonClick = (event: any) => {
|
||||
break;
|
||||
}
|
||||
case 'importFile': {
|
||||
isImportFileVisible.value = true;
|
||||
importDocModalRef.value?.openDialog?.();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleCategoryClick = (menuKey: string) => {
|
||||
selectedCategory.value = menuKey;
|
||||
viewDocVisible.value = false;
|
||||
panelMode.value = 'list';
|
||||
};
|
||||
const viewDocVisible = ref(false);
|
||||
const documentId = ref('');
|
||||
// 子组件传递事件,显示查看文档详情
|
||||
const viewDoc = (docId: string) => {
|
||||
viewDocVisible.value = true;
|
||||
panelMode.value = 'chunk';
|
||||
documentId.value = docId;
|
||||
};
|
||||
const backDoc = () => {
|
||||
isImportFileVisible.value = false;
|
||||
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?.();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="document-container">
|
||||
<div v-if="!isImportFileVisible" class="doc-header-container">
|
||||
<div class="doc-header-container">
|
||||
<div class="doc-knowledge-container">
|
||||
<div @click="back()" style="cursor: pointer">
|
||||
<ElIcon><ArrowLeft /></ElIcon>
|
||||
@@ -215,9 +226,10 @@ const backDoc = () => {
|
||||
<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="!viewDocVisible">
|
||||
<div class="doc-header" v-if="panelMode === 'list'">
|
||||
<HeaderSearch
|
||||
v-if="!isFaqCollection"
|
||||
:buttons="canManageCurrentKnowledge ? headerButtons : []"
|
||||
@@ -225,20 +237,38 @@ const backDoc = () => {
|
||||
@button-click="handleButtonClick"
|
||||
/>
|
||||
</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"
|
||||
:manageable="canManageCurrentKnowledge"
|
||||
:permissions="{
|
||||
canCreateContent: canManageCurrentKnowledge,
|
||||
canDeleteContent: canManageCurrentKnowledge,
|
||||
canDownloadContent: true,
|
||||
}"
|
||||
@continue-process="continueProcess"
|
||||
@view-doc="viewDoc"
|
||||
v-if="!viewDocVisible"
|
||||
/>
|
||||
|
||||
<ChunkDocumentTable
|
||||
v-else
|
||||
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
|
||||
@@ -251,11 +281,7 @@ const backDoc = () => {
|
||||
v-if="selectedCategory === 'knowledgeSearch'"
|
||||
class="doc-search-container"
|
||||
>
|
||||
<KnowledgeSearchConfig
|
||||
:document-collection-id="knowledgeId"
|
||||
:manageable="canManageCurrentKnowledge"
|
||||
/>
|
||||
<KnowledgeSearch :knowledge-id="knowledgeId" />
|
||||
<KnowledgeSearch :knowledge-id="knowledgeId" :show-config="true" />
|
||||
</div>
|
||||
<!--配置-->
|
||||
<div v-if="selectedCategory === 'config'">
|
||||
@@ -275,9 +301,11 @@ const backDoc = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="doc-imp-container">
|
||||
<ImportKnowledgeDocFile @import-back="backDoc" />
|
||||
</div>
|
||||
<ImportKnowledgeDocFile
|
||||
ref="importDocModalRef"
|
||||
:knowledge-id-prop="String(knowledgeId)"
|
||||
@imported="backDoc"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -306,6 +334,12 @@ const backDoc = () => {
|
||||
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;
|
||||
@@ -378,10 +412,8 @@ const backDoc = () => {
|
||||
background-color: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.doc-imp-container {
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
.doc-sub-back {
|
||||
padding: 0 6px 16px;
|
||||
}
|
||||
|
||||
.doc-header-container {
|
||||
@@ -428,6 +460,8 @@ const backDoc = () => {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.menu-container {
|
||||
|
||||
@@ -1,36 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { $t } from '@easyflow/locales';
|
||||
import { downloadFileFromBlob } from '@easyflow/utils';
|
||||
|
||||
import { Delete, Download, MoreFilled } from '@element-plus/icons-vue';
|
||||
import {
|
||||
CloseBold,
|
||||
Delete,
|
||||
Download,
|
||||
Files,
|
||||
Loading,
|
||||
Opportunity,
|
||||
Select,
|
||||
UploadFilled,
|
||||
} from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElDropdown,
|
||||
ElDropdownItem,
|
||||
ElDropdownMenu,
|
||||
ElIcon,
|
||||
ElImage,
|
||||
ElMessage,
|
||||
ElMessageBox,
|
||||
ElProgress,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
ElTooltip,
|
||||
} from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import { buildKnowledgeShareUrl } from '#/api/knowledge-share';
|
||||
import { api, SseClient } from '#/api/request';
|
||||
import documentIcon from '#/assets/ai/knowledge/document.svg';
|
||||
import PageData from '#/components/page/PageData.vue';
|
||||
import { buildKnowledgePath } from '#/views/ai/documentCollection/share-path';
|
||||
|
||||
interface DocumentStatusPayload {
|
||||
completedChunks?: number;
|
||||
documentId?: number | string;
|
||||
failedChunks?: number;
|
||||
knowledgeId?: number | string;
|
||||
lastTaskError?: string;
|
||||
processStatus?: string;
|
||||
progressPercent?: number;
|
||||
taskModifiedAt?: string;
|
||||
totalChunks?: number;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
interface DocumentTablePermissions {
|
||||
canCreateContent?: boolean;
|
||||
canDeleteContent?: boolean;
|
||||
canDownloadContent?: boolean;
|
||||
}
|
||||
|
||||
type PermissionKey = keyof DocumentTablePermissions;
|
||||
|
||||
const props = defineProps({
|
||||
knowledgeId: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
manageable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
requestClient: {
|
||||
type: Object as any,
|
||||
default: () => api,
|
||||
@@ -39,19 +68,283 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
permissions: {
|
||||
type: Object as () => DocumentTablePermissions,
|
||||
default: () => ({
|
||||
canCreateContent: true,
|
||||
canDeleteContent: true,
|
||||
canDownloadContent: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const emits = defineEmits(['viewDoc']);
|
||||
|
||||
const emits = defineEmits(['viewDoc', 'continueProcess']);
|
||||
|
||||
const STREAM_RECONNECT_DELAY = 1500;
|
||||
const STREAM_RELOAD_DELAY = 250;
|
||||
|
||||
const pageDataRef = ref();
|
||||
const taskStatusStreamClient = new SseClient();
|
||||
let reconnectTimer: null | ReturnType<typeof setTimeout> = null;
|
||||
let reloadTimer: null | ReturnType<typeof setTimeout> = null;
|
||||
let disposed = false;
|
||||
|
||||
defineExpose({
|
||||
reload() {
|
||||
pageDataRef.value?.reload?.();
|
||||
},
|
||||
search(searchText: string) {
|
||||
pageDataRef.value.setQuery({
|
||||
pageDataRef.value?.setQuery?.({
|
||||
title: searchText,
|
||||
});
|
||||
},
|
||||
});
|
||||
const pageDataRef = ref();
|
||||
|
||||
const processingStatuses = new Set(['INDEXING', 'PARSING']);
|
||||
|
||||
const isProcessingStatus = (status?: string) =>
|
||||
processingStatuses.has(status || '');
|
||||
|
||||
const resolvedPermissions = computed(() => ({
|
||||
canCreateContent: props.permissions?.canCreateContent ?? true,
|
||||
canDeleteContent: props.permissions?.canDeleteContent ?? true,
|
||||
canDownloadContent: props.permissions?.canDownloadContent ?? true,
|
||||
}));
|
||||
|
||||
const hasPermission = (key: PermissionKey) => Boolean(resolvedPermissions.value[key]);
|
||||
|
||||
const statusMetaMap: Record<
|
||||
string,
|
||||
{
|
||||
icon: Component;
|
||||
toneClass: string;
|
||||
}
|
||||
> = {
|
||||
COMPLETED: {
|
||||
icon: Select,
|
||||
toneClass: 'status-pill--success',
|
||||
},
|
||||
INDEX_FAILED: {
|
||||
icon: CloseBold,
|
||||
toneClass: 'status-pill--danger',
|
||||
},
|
||||
INDEXING: {
|
||||
icon: Loading,
|
||||
toneClass: 'status-pill--warning',
|
||||
},
|
||||
PARSE_FAILED: {
|
||||
icon: CloseBold,
|
||||
toneClass: 'status-pill--danger',
|
||||
},
|
||||
PARSING: {
|
||||
icon: Loading,
|
||||
toneClass: 'status-pill--warning',
|
||||
},
|
||||
READY_FOR_INDEX: {
|
||||
icon: Opportunity,
|
||||
toneClass: 'status-pill--primary',
|
||||
},
|
||||
READY_FOR_SEGMENT: {
|
||||
icon: Files,
|
||||
toneClass: 'status-pill--primary',
|
||||
},
|
||||
UPLOADED: {
|
||||
icon: UploadFilled,
|
||||
toneClass: 'status-pill--info',
|
||||
},
|
||||
};
|
||||
|
||||
const getStatusLabel = (status?: string) =>
|
||||
$t(`documentCollection.taskStatus.${status || 'UPLOADED'}`);
|
||||
|
||||
const getStatusMeta = (status?: string) =>
|
||||
statusMetaMap[status || 'UPLOADED'] || statusMetaMap.UPLOADED;
|
||||
|
||||
const getChunkCount = (row: any) => {
|
||||
const totalChunks = Number(row.totalChunks || 0);
|
||||
if (totalChunks > 0) {
|
||||
return totalChunks;
|
||||
}
|
||||
return Number(row.chunkCount || 0);
|
||||
};
|
||||
|
||||
const getProgressText = (row: any) => {
|
||||
const completed = Number(row.completedChunks || 0);
|
||||
const total = Number(row.totalChunks || 0);
|
||||
if (total <= 0) {
|
||||
return `${Number(row.progressPercent || 0)}%`;
|
||||
}
|
||||
return `${Number(row.progressPercent || 0)}% · ${completed}/${total}`;
|
||||
};
|
||||
|
||||
const clearReconnectTimer = () => {
|
||||
if (!reconnectTimer) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
};
|
||||
|
||||
const clearReloadTimer = () => {
|
||||
if (!reloadTimer) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(reloadTimer);
|
||||
reloadTimer = null;
|
||||
};
|
||||
|
||||
const scheduleReload = () => {
|
||||
if (reloadTimer) {
|
||||
return;
|
||||
}
|
||||
reloadTimer = setTimeout(() => {
|
||||
reloadTimer = null;
|
||||
pageDataRef.value?.reload?.();
|
||||
}, STREAM_RELOAD_DELAY);
|
||||
};
|
||||
|
||||
const patchDocumentRow = (payload: DocumentStatusPayload) => {
|
||||
if (!payload.documentId) {
|
||||
return false;
|
||||
}
|
||||
const nextPatch: Record<string, any> = {
|
||||
completedChunks: payload.completedChunks,
|
||||
failedChunks: payload.failedChunks,
|
||||
lastTaskError: payload.lastTaskError,
|
||||
processStatus: payload.processStatus,
|
||||
progressPercent: payload.progressPercent,
|
||||
taskModifiedAt: payload.taskModifiedAt,
|
||||
totalChunks: payload.totalChunks,
|
||||
};
|
||||
if (payload.taskModifiedAt) {
|
||||
nextPatch.modified = payload.taskModifiedAt;
|
||||
}
|
||||
return (
|
||||
pageDataRef.value?.patchRowById?.(payload.documentId, nextPatch) ?? false
|
||||
);
|
||||
};
|
||||
|
||||
const buildTaskStreamUrl = () => {
|
||||
const path = buildKnowledgePath(
|
||||
props.endpointPrefix,
|
||||
'/api/v1/document/import/task/stream',
|
||||
);
|
||||
return props.endpointPrefix ? buildKnowledgeShareUrl(path) : path;
|
||||
};
|
||||
|
||||
const scheduleStreamReconnect = () => {
|
||||
if (disposed || reconnectTimer) {
|
||||
return;
|
||||
}
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
openTaskStatusStream();
|
||||
}, STREAM_RECONNECT_DELAY);
|
||||
};
|
||||
|
||||
const handleTaskStatusMessage = (message: {
|
||||
data?: string;
|
||||
event?: string;
|
||||
}) => {
|
||||
if (!message?.data) {
|
||||
return;
|
||||
}
|
||||
if (message.event && message.event !== 'document-status') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(message.data) as DocumentStatusPayload;
|
||||
if (payload?.type !== 'document-status') {
|
||||
return;
|
||||
}
|
||||
if (String(payload.knowledgeId || '') !== String(props.knowledgeId || '')) {
|
||||
return;
|
||||
}
|
||||
if (patchDocumentRow(payload)) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// 文档状态流约定始终返回 JSON;异常负载直接忽略,避免误触发列表刷新。
|
||||
return;
|
||||
}
|
||||
scheduleReload();
|
||||
};
|
||||
|
||||
const openTaskStatusStream = async () => {
|
||||
if (!props.knowledgeId) {
|
||||
return;
|
||||
}
|
||||
taskStatusStreamClient.abort();
|
||||
clearReconnectTimer();
|
||||
await taskStatusStreamClient.post(
|
||||
buildTaskStreamUrl(),
|
||||
{
|
||||
knowledgeId: props.knowledgeId,
|
||||
},
|
||||
{
|
||||
onMessage: handleTaskStatusMessage,
|
||||
onError: () => {
|
||||
if (!disposed) {
|
||||
scheduleStreamReconnect();
|
||||
}
|
||||
},
|
||||
onFinished: () => {
|
||||
if (!disposed) {
|
||||
scheduleStreamReconnect();
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const ensurePermission = (key: PermissionKey) => {
|
||||
if (hasPermission(key)) {
|
||||
return true;
|
||||
}
|
||||
ElMessage.warning($t('documentCollection.managePermissionHint'));
|
||||
return false;
|
||||
};
|
||||
|
||||
const requestTaskAction = async (
|
||||
path: string,
|
||||
payload: Record<string, any>,
|
||||
successMessage: string,
|
||||
) => {
|
||||
if (!ensurePermission('canCreateContent')) {
|
||||
return;
|
||||
}
|
||||
const res = await props.requestClient.post(
|
||||
buildKnowledgePath(props.endpointPrefix, path),
|
||||
payload,
|
||||
);
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success(successMessage);
|
||||
pageDataRef.value?.reload?.();
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinue = (row: any) => {
|
||||
if (!ensurePermission('canCreateContent')) {
|
||||
return;
|
||||
}
|
||||
emits('continueProcess', row);
|
||||
};
|
||||
|
||||
const handleRetryParse = async (row: any) => {
|
||||
await requestTaskAction(
|
||||
'/api/v1/document/import/task/retryParse',
|
||||
{
|
||||
knowledgeId: props.knowledgeId,
|
||||
documentId: row.id,
|
||||
},
|
||||
getStatusLabel('PARSING'),
|
||||
);
|
||||
};
|
||||
|
||||
const handleView = (row: any) => {
|
||||
emits('viewDoc', row.id);
|
||||
};
|
||||
|
||||
const handleDownload = async (row: any) => {
|
||||
const blob = await props.requestClient.download(
|
||||
buildKnowledgePath(
|
||||
@@ -64,30 +357,108 @@ const handleDownload = async (row: any) => {
|
||||
source: blob,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (row: any) => {
|
||||
if (!props.manageable) {
|
||||
ElMessage.warning($t('documentCollection.managePermissionHint'));
|
||||
if (!ensurePermission('canDeleteContent')) {
|
||||
return;
|
||||
}
|
||||
if (processingStatuses.has(row.processStatus)) {
|
||||
ElMessage.warning($t('documentCollection.processingDeleteBlocked'));
|
||||
return;
|
||||
}
|
||||
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
|
||||
confirmButtonText: $t('button.confirm'),
|
||||
cancelButtonText: $t('button.cancel'),
|
||||
type: 'warning',
|
||||
}).then(() => {
|
||||
props.requestClient
|
||||
.post(
|
||||
buildKnowledgePath(props.endpointPrefix, '/api/v1/document/removeDoc'),
|
||||
{ id: row.id },
|
||||
)
|
||||
.then((res: any) => {
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success($t('message.deleteOkMessage'));
|
||||
pageDataRef.value.setQuery({ id: props.knowledgeId });
|
||||
}
|
||||
});
|
||||
// 删除逻辑
|
||||
}).then(async () => {
|
||||
const res = await props.requestClient.post(
|
||||
buildKnowledgePath(props.endpointPrefix, '/api/v1/document/removeDoc'),
|
||||
{ id: row.id },
|
||||
);
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success($t('message.deleteOkMessage'));
|
||||
pageDataRef.value?.reload?.();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const primaryActionConfigs: Record<
|
||||
string,
|
||||
{
|
||||
handler: (row: any) => void;
|
||||
label: () => string;
|
||||
permission?: PermissionKey;
|
||||
}
|
||||
> = {
|
||||
COMPLETED: {
|
||||
handler: handleView,
|
||||
label: () => $t('button.viewSegmentation'),
|
||||
},
|
||||
INDEX_FAILED: {
|
||||
handler: handleContinue,
|
||||
label: () => $t('button.continueProcess'),
|
||||
permission: 'canCreateContent',
|
||||
},
|
||||
PARSE_FAILED: {
|
||||
handler: handleRetryParse,
|
||||
label: () => $t('button.retryParse'),
|
||||
permission: 'canCreateContent',
|
||||
},
|
||||
READY_FOR_INDEX: {
|
||||
handler: handleContinue,
|
||||
label: () => $t('button.continueProcess'),
|
||||
permission: 'canCreateContent',
|
||||
},
|
||||
READY_FOR_SEGMENT: {
|
||||
handler: handleContinue,
|
||||
label: () => $t('button.continueProcess'),
|
||||
permission: 'canCreateContent',
|
||||
},
|
||||
};
|
||||
|
||||
const getPrimaryActionLabel = (row: any) => {
|
||||
const config = primaryActionConfigs[row.processStatus || ''];
|
||||
if (!config) {
|
||||
return '';
|
||||
}
|
||||
if (config.permission && !hasPermission(config.permission)) {
|
||||
return '';
|
||||
}
|
||||
return config.label();
|
||||
};
|
||||
|
||||
const handlePrimaryAction = (row: any) => {
|
||||
const config = primaryActionConfigs[row.processStatus || ''];
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
if (config.permission && !hasPermission(config.permission)) {
|
||||
return;
|
||||
}
|
||||
config.handler(row);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
disposed = false;
|
||||
openTaskStatusStream();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true;
|
||||
clearReconnectTimer();
|
||||
clearReloadTimer();
|
||||
taskStatusStreamClient.abort();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => `${props.endpointPrefix}:${props.knowledgeId}`,
|
||||
() => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
openTaskStatusStream();
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -109,6 +480,9 @@ const handleDelete = (row: any) => {
|
||||
<ElTableColumn
|
||||
prop="fileName"
|
||||
:label="$t('documentCollection.fileName')"
|
||||
min-width="220"
|
||||
align="left"
|
||||
header-align="left"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<span class="file-name-container">
|
||||
@@ -119,19 +493,23 @@ const handleDelete = (row: any) => {
|
||||
</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn
|
||||
prop="documentType"
|
||||
:label="$t('documentCollection.documentType')"
|
||||
width="180"
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="chunkCount"
|
||||
:label="$t('documentCollection.knowledgeCount')"
|
||||
width="180"
|
||||
/>
|
||||
:label="$t('documentCollection.chunkCount')"
|
||||
width="96"
|
||||
align="left"
|
||||
header-align="left"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ getChunkCount(row) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn
|
||||
:label="$t('documentCollection.createdModifyTime')"
|
||||
width="200"
|
||||
width="176"
|
||||
align="left"
|
||||
header-align="left"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<div class="time-container">
|
||||
@@ -140,34 +518,101 @@ const handleDelete = (row: any) => {
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="$t('common.handle')" width="120" align="right">
|
||||
|
||||
<ElTableColumn
|
||||
:label="$t('documentCollection.processStatus')"
|
||||
min-width="156"
|
||||
align="left"
|
||||
header-align="left"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-center gap-3">
|
||||
<ElButton link type="primary" @click="handleView(row)">
|
||||
{{ $t('button.viewSegmentation') }}
|
||||
<div class="status-cell">
|
||||
<div
|
||||
class="status-pill"
|
||||
:class="getStatusMeta(row.processStatus).toneClass"
|
||||
>
|
||||
<span class="status-pill__icon-shell">
|
||||
<ElIcon
|
||||
class="status-pill__icon"
|
||||
:class="
|
||||
isProcessingStatus(row.processStatus)
|
||||
? 'status-pill__icon--spinning'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<component :is="getStatusMeta(row.processStatus).icon" />
|
||||
</ElIcon>
|
||||
</span>
|
||||
<span class="status-pill__label">
|
||||
{{ getStatusLabel(row.processStatus) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="row.processStatus === 'INDEXING'"
|
||||
class="status-progress"
|
||||
>
|
||||
<ElProgress
|
||||
:percentage="Number(row.progressPercent || 0)"
|
||||
:stroke-width="8"
|
||||
/>
|
||||
<span class="status-progress__text">
|
||||
{{ getProgressText(row) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="row.lastTaskError"
|
||||
class="status-error"
|
||||
:title="row.lastTaskError"
|
||||
>
|
||||
{{ row.lastTaskError }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn
|
||||
:label="$t('common.handle')"
|
||||
width="148"
|
||||
align="left"
|
||||
header-align="left"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<div class="action-cell">
|
||||
<ElButton
|
||||
v-if="getPrimaryActionLabel(row)"
|
||||
link
|
||||
type="primary"
|
||||
@click="handlePrimaryAction(row)"
|
||||
>
|
||||
{{ getPrimaryActionLabel(row) }}
|
||||
</ElButton>
|
||||
|
||||
<ElDropdown>
|
||||
<ElButton link :icon="MoreFilled" />
|
||||
<ElTooltip
|
||||
v-if="hasPermission('canDownloadContent')"
|
||||
:content="$t('button.download')"
|
||||
placement="top"
|
||||
>
|
||||
<ElButton
|
||||
link
|
||||
:icon="Download"
|
||||
:aria-label="$t('button.download')"
|
||||
@click="handleDownload(row)"
|
||||
/>
|
||||
</ElTooltip>
|
||||
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem @click="handleDownload(row)">
|
||||
<ElButton link :icon="Download">
|
||||
{{ $t('button.download') }}
|
||||
</ElButton>
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
v-if="props.manageable"
|
||||
@click="handleDelete(row)"
|
||||
>
|
||||
<ElButton link :icon="Delete" type="danger">
|
||||
{{ $t('button.delete') }}
|
||||
</ElButton>
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
<ElTooltip
|
||||
v-if="hasPermission('canDeleteContent')"
|
||||
:content="$t('button.delete')"
|
||||
placement="top"
|
||||
>
|
||||
<ElButton
|
||||
link
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
:aria-label="$t('button.delete')"
|
||||
@click="handleDelete(row)"
|
||||
/>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
@@ -180,21 +625,166 @@ const handleDelete = (row: any) => {
|
||||
.time-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.file-name-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
color: #1a1a1a;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.status-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.status-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: min(168px, 100%);
|
||||
}
|
||||
|
||||
.status-progress__text {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
text-align: left;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
max-width: 176px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--el-color-danger);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
min-height: 30px;
|
||||
padding: 4px 12px 4px 8px;
|
||||
border: 1px solid var(--status-pill-border);
|
||||
border-radius: 999px;
|
||||
background: var(--status-pill-bg);
|
||||
color: var(--status-pill-text);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
color 0.2s ease;
|
||||
}
|
||||
|
||||
.status-pill__icon-shell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
background: var(--status-pill-icon-bg);
|
||||
}
|
||||
|
||||
.status-pill__icon {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: var(--status-pill-icon-color);
|
||||
}
|
||||
|
||||
.status-pill__label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.status-pill__icon--spinning {
|
||||
animation: status-spin 1.15s linear infinite;
|
||||
}
|
||||
|
||||
.status-pill--primary {
|
||||
--status-pill-bg: var(--el-color-primary-light-9);
|
||||
--status-pill-border: var(--el-color-primary-light-7);
|
||||
--status-pill-icon-bg: var(--el-color-primary-light-8);
|
||||
--status-pill-icon-color: var(--el-color-primary);
|
||||
--status-pill-text: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.status-pill--success {
|
||||
--status-pill-bg: var(--el-color-success-light-9);
|
||||
--status-pill-border: var(--el-color-success-light-7);
|
||||
--status-pill-icon-bg: var(--el-color-success-light-8);
|
||||
--status-pill-icon-color: var(--el-color-success);
|
||||
--status-pill-text: var(--el-color-success);
|
||||
}
|
||||
|
||||
.status-pill--warning {
|
||||
--status-pill-bg: var(--el-color-warning-light-9);
|
||||
--status-pill-border: var(--el-color-warning-light-7);
|
||||
--status-pill-icon-bg: var(--el-color-warning-light-8);
|
||||
--status-pill-icon-color: var(--el-color-warning);
|
||||
--status-pill-text: var(--el-color-warning);
|
||||
}
|
||||
|
||||
.status-pill--danger {
|
||||
--status-pill-bg: var(--el-color-danger-light-9);
|
||||
--status-pill-border: var(--el-color-danger-light-7);
|
||||
--status-pill-icon-bg: var(--el-color-danger-light-8);
|
||||
--status-pill-icon-color: var(--el-color-danger);
|
||||
--status-pill-text: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.status-pill--info {
|
||||
--status-pill-bg: var(--el-fill-color-light);
|
||||
--status-pill-border: var(--el-border-color-light);
|
||||
--status-pill-icon-bg: var(--el-fill-color);
|
||||
--status-pill-icon-color: var(--el-text-color-secondary);
|
||||
--status-pill-text: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.action-cell {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-cell :deep(.el-button) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-cell :deep(.el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
@keyframes status-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,35 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
||||
import { $t } from '@easyflow/locales';
|
||||
|
||||
import { Back } from '@element-plus/icons-vue';
|
||||
import { ElButton, ElMessage, ElStep, ElSteps } from 'element-plus';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import ComfirmImportDocument from '#/views/ai/documentCollection/ComfirmImportDocument.vue';
|
||||
import ImportKnowledgeFileContainer from '#/views/ai/documentCollection/ImportKnowledgeFileContainer.vue';
|
||||
import SegmenterDoc from '#/views/ai/documentCollection/SegmenterDoc.vue';
|
||||
import { buildKnowledgePath } from '#/views/ai/documentCollection/share-path';
|
||||
import SplitterDocPreview from '#/views/ai/documentCollection/SplitterDocPreview.vue';
|
||||
|
||||
interface UploadFileItem {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
interface AnalyzeItem {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
strategyConfig: Record<string, any>;
|
||||
}
|
||||
|
||||
interface PreviewItem {
|
||||
fileName: string;
|
||||
previewSessionId: string;
|
||||
totalChunks?: number;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
requestClient: {
|
||||
@@ -46,283 +26,165 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['importBack']);
|
||||
const emits = defineEmits(['imported']);
|
||||
|
||||
const route = useRoute();
|
||||
const fileUploadRef = ref<InstanceType<typeof ImportKnowledgeFileContainer>>();
|
||||
const dialogVisible = ref(false);
|
||||
const submitting = ref(false);
|
||||
|
||||
const knowledgeId = computed(
|
||||
() => props.knowledgeIdProp || (route.query.id as string) || '',
|
||||
);
|
||||
|
||||
const fileUploadRef = ref<InstanceType<typeof ImportKnowledgeFileContainer>>();
|
||||
const segmenterDocRef = ref<InstanceType<typeof SegmenterDoc>>();
|
||||
const resetDialogState = () => {
|
||||
fileUploadRef.value?.reset?.();
|
||||
};
|
||||
|
||||
const activeStep = ref(0);
|
||||
const files = ref<UploadFileItem[]>([]);
|
||||
const analysisItems = ref<AnalyzeItem[]>([]);
|
||||
const previewItems = ref<PreviewItem[]>([]);
|
||||
const commitResults = ref<any[]>([]);
|
||||
|
||||
const analyzing = ref(false);
|
||||
const previewing = ref(false);
|
||||
const committing = ref(false);
|
||||
let autoBackTimer: null | ReturnType<typeof setTimeout> = null;
|
||||
|
||||
const canGoPrevious = computed(() => activeStep.value > 0 && !committing.value);
|
||||
|
||||
function back() {
|
||||
emits('importBack');
|
||||
}
|
||||
|
||||
function scheduleBackAfterSuccess() {
|
||||
if (autoBackTimer) {
|
||||
clearTimeout(autoBackTimer);
|
||||
}
|
||||
autoBackTimer = setTimeout(() => {
|
||||
back();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function getUploadedFiles() {
|
||||
return fileUploadRef.value?.getFilesData?.() || [];
|
||||
}
|
||||
|
||||
async function goToNextStep() {
|
||||
if (activeStep.value === 0) {
|
||||
const currentFiles = getUploadedFiles();
|
||||
if (currentFiles.length === 0) {
|
||||
ElMessage.error($t('message.uploadFileFirst'));
|
||||
return;
|
||||
}
|
||||
files.value = currentFiles;
|
||||
const analyzed = await runAnalyze();
|
||||
if (analyzed) {
|
||||
activeStep.value = 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeStep.value === 1) {
|
||||
const previewed = await runPreview();
|
||||
if (previewed) {
|
||||
activeStep.value = 2;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeStep.value === 2) {
|
||||
activeStep.value = 3;
|
||||
}
|
||||
}
|
||||
|
||||
function goToPreviousStep() {
|
||||
if (!canGoPrevious.value) {
|
||||
return;
|
||||
}
|
||||
activeStep.value -= 1;
|
||||
}
|
||||
|
||||
async function runAnalyze() {
|
||||
analyzing.value = true;
|
||||
try {
|
||||
const payload: Record<string, any> = {
|
||||
files: files.value.map((item) => ({
|
||||
fileName: item.fileName,
|
||||
filePath: item.filePath,
|
||||
})),
|
||||
};
|
||||
if (knowledgeId.value) {
|
||||
payload.knowledgeId = knowledgeId.value;
|
||||
}
|
||||
const res = await props.requestClient.post(
|
||||
buildKnowledgePath(
|
||||
props.endpointPrefix,
|
||||
'/api/v1/document/import/analyze',
|
||||
),
|
||||
payload,
|
||||
);
|
||||
analysisItems.value = res.data?.items || [];
|
||||
return true;
|
||||
} finally {
|
||||
analyzing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runPreview() {
|
||||
const previewRequestItems =
|
||||
segmenterDocRef.value?.getPreviewRequestItems?.() || [];
|
||||
if (previewRequestItems.length === 0) {
|
||||
ElMessage.error($t('documentCollection.importDoc.previewEmpty'));
|
||||
const closeDialog = () => {
|
||||
if (submitting.value) {
|
||||
return false;
|
||||
}
|
||||
previewing.value = true;
|
||||
try {
|
||||
const payload: Record<string, any> = {
|
||||
files: previewRequestItems,
|
||||
};
|
||||
if (knowledgeId.value) {
|
||||
payload.knowledgeId = knowledgeId.value;
|
||||
}
|
||||
const res = await props.requestClient.post(
|
||||
buildKnowledgePath(
|
||||
props.endpointPrefix,
|
||||
'/api/v1/document/import/preview',
|
||||
),
|
||||
payload,
|
||||
);
|
||||
previewItems.value = res.data?.items || [];
|
||||
commitResults.value = [];
|
||||
return true;
|
||||
} finally {
|
||||
previewing.value = false;
|
||||
}
|
||||
}
|
||||
dialogVisible.value = false;
|
||||
resetDialogState();
|
||||
return true;
|
||||
};
|
||||
|
||||
async function confirmImport() {
|
||||
if (previewItems.value.length === 0) {
|
||||
ElMessage.error($t('documentCollection.importDoc.previewEmpty'));
|
||||
const openDialog = () => {
|
||||
resetDialogState();
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const createTasks = async () => {
|
||||
const files = fileUploadRef.value?.getFilesData?.() || [];
|
||||
if (files.length === 0) {
|
||||
ElMessage.error($t('message.uploadFileFirst'));
|
||||
return;
|
||||
}
|
||||
committing.value = true;
|
||||
try {
|
||||
const payload: Record<string, any> = {
|
||||
previewSessionIds: previewItems.value.map(
|
||||
(item) => item.previewSessionId,
|
||||
),
|
||||
};
|
||||
if (knowledgeId.value) {
|
||||
payload.knowledgeId = knowledgeId.value;
|
||||
}
|
||||
const res = await props.requestClient.post(
|
||||
buildKnowledgePath(
|
||||
props.endpointPrefix,
|
||||
'/api/v1/document/import/commit',
|
||||
),
|
||||
payload,
|
||||
);
|
||||
commitResults.value = res.data?.results || [];
|
||||
if ((res.data?.errorCount || 0) === 0) {
|
||||
ElMessage.success($t('documentCollection.splitterDoc.importSuccess'));
|
||||
scheduleBackAfterSuccess();
|
||||
}
|
||||
} finally {
|
||||
committing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (autoBackTimer) {
|
||||
clearTimeout(autoBackTimer);
|
||||
submitting.value = true;
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
try {
|
||||
for (const file of files) {
|
||||
try {
|
||||
const res = await props.requestClient.post(
|
||||
buildKnowledgePath(
|
||||
props.endpointPrefix,
|
||||
'/api/v1/document/import/task/create',
|
||||
),
|
||||
{
|
||||
knowledgeId: knowledgeId.value,
|
||||
fileName: file.fileName,
|
||||
filePath: file.filePath,
|
||||
},
|
||||
);
|
||||
if (res.errorCode === 0) {
|
||||
successCount += 1;
|
||||
} else {
|
||||
failedCount += 1;
|
||||
}
|
||||
} catch {
|
||||
failedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
ElMessage.success(
|
||||
failedCount > 0
|
||||
? $t('documentCollection.importDoc.partialCreateSuccess')
|
||||
: $t('documentCollection.importDoc.createSuccess'),
|
||||
);
|
||||
dialogVisible.value = false;
|
||||
resetDialogState();
|
||||
emits('imported');
|
||||
return;
|
||||
}
|
||||
|
||||
ElMessage.error($t('documentCollection.importDoc.importFailed'));
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
closeDialog,
|
||||
openDialog,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="imp-doc-kno-container">
|
||||
<div class="imp-doc-header">
|
||||
<ElButton :icon="Back" @click="back">
|
||||
{{ $t('button.back') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<EasyFlowFormModal
|
||||
v-model:open="dialogVisible"
|
||||
:title="$t('button.importFile')"
|
||||
:before-close="closeDialog"
|
||||
:closable="!submitting"
|
||||
:centered="true"
|
||||
:confirm-loading="submitting"
|
||||
:confirm-text="$t('button.importFile')"
|
||||
:submitting="submitting"
|
||||
width="xl"
|
||||
@confirm="createTasks"
|
||||
>
|
||||
<div class="import-dialog">
|
||||
<p class="import-dialog__tip">
|
||||
{{ $t('documentCollection.importDoc.uploadCreateTip') }}
|
||||
</p>
|
||||
|
||||
<div class="imp-doc-kno-content">
|
||||
<div class="step-card">
|
||||
<ElSteps :active="activeStep" align-center>
|
||||
<ElStep :title="$t('documentCollection.importDoc.fileUpload')" />
|
||||
<ElStep
|
||||
:title="$t('documentCollection.importDoc.strategyAnalysis')"
|
||||
/>
|
||||
<ElStep
|
||||
:title="$t('documentCollection.importDoc.segmentedPreview')"
|
||||
/>
|
||||
<ElStep :title="$t('documentCollection.importDoc.confirmImport')" />
|
||||
</ElSteps>
|
||||
</div>
|
||||
|
||||
<div class="step-body">
|
||||
<ImportKnowledgeFileContainer
|
||||
v-if="activeStep === 0"
|
||||
ref="fileUploadRef"
|
||||
/>
|
||||
<SegmenterDoc
|
||||
v-else-if="activeStep === 1"
|
||||
ref="segmenterDocRef"
|
||||
:analysis-items="analysisItems"
|
||||
/>
|
||||
<SplitterDocPreview
|
||||
v-else-if="activeStep === 2"
|
||||
:preview-items="previewItems"
|
||||
/>
|
||||
<ComfirmImportDocument
|
||||
v-else
|
||||
:preview-items="previewItems"
|
||||
:commit-results="commitResults"
|
||||
:loading="committing"
|
||||
/>
|
||||
</div>
|
||||
<ImportKnowledgeFileContainer ref="fileUploadRef" />
|
||||
</div>
|
||||
|
||||
<div class="imp-doc-footer">
|
||||
<ElButton v-if="canGoPrevious" @click="goToPreviousStep">
|
||||
{{ $t('button.previousStep') }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="activeStep < 3"
|
||||
type="primary"
|
||||
:loading="analyzing || previewing"
|
||||
@click="goToNextStep"
|
||||
>
|
||||
{{ $t('button.nextStep') }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-else
|
||||
type="primary"
|
||||
:loading="committing"
|
||||
:disabled="committing"
|
||||
@click="confirmImport"
|
||||
>
|
||||
{{ $t('button.startImport') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</EasyFlowFormModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.imp-doc-kno-container {
|
||||
position: relative;
|
||||
.import-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 24px;
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.imp-doc-kno-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding-top: 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.step-card {
|
||||
padding: 20px 24px;
|
||||
background: var(--el-fill-color-blank);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.step-body {
|
||||
flex: 1;
|
||||
padding-bottom: 72px;
|
||||
}
|
||||
|
||||
.imp-doc-footer {
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.import-dialog__tip {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
:deep(.upload-demo) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.upload-demo .el-upload) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.upload-demo .el-upload-dragger) {
|
||||
padding: 24px 20px;
|
||||
border-radius: 18px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.upload-demo .el-icon) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-table) {
|
||||
--el-table-border-color: transparent;
|
||||
}
|
||||
|
||||
:deep(.el-table__inner-wrapper::before) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.el-table th.el-table__cell) {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:deep(.el-table td.el-table__cell) {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,10 +18,16 @@ interface FileInfo {
|
||||
}
|
||||
const fileData = ref<FileInfo[]>([]);
|
||||
const filesPath = ref([]);
|
||||
const dragUploadRef = ref<InstanceType<typeof DragFileUpload>>();
|
||||
defineExpose({
|
||||
getFilesData() {
|
||||
return fileData.value.filter((item) => item.filePath);
|
||||
},
|
||||
reset() {
|
||||
fileData.value = [];
|
||||
filesPath.value = [];
|
||||
dragUploadRef.value?.clearFiles?.();
|
||||
},
|
||||
});
|
||||
function handleSuccess(response: any) {
|
||||
filesPath.value = response.data;
|
||||
@@ -59,11 +65,13 @@ function handleRemove(row: any) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div>
|
||||
<DragFileUpload @success="handleSuccess" @on-change="handleChange" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="import-file-container">
|
||||
<DragFileUpload
|
||||
ref="dragUploadRef"
|
||||
@success="handleSuccess"
|
||||
@on-change="handleChange"
|
||||
/>
|
||||
<div class="import-file-container__table">
|
||||
<ElTable :data="fileData" style="width: 100%" size="large">
|
||||
<ElTableColumn
|
||||
prop="fileName"
|
||||
@@ -104,4 +112,14 @@ function handleRemove(row: any) {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
.import-file-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.import-file-container__table {
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { $t } from '@easyflow/locales';
|
||||
|
||||
import { ElButton, ElInput, ElMessage } from 'element-plus';
|
||||
import { InfoFilled } from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElInput,
|
||||
ElInputNumber,
|
||||
ElMessage,
|
||||
ElTooltip,
|
||||
} from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import PreviewSearchKnowledge from '#/views/ai/documentCollection/PreviewSearchKnowledge.vue';
|
||||
@@ -33,12 +40,22 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showConfig: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const searchDataList = ref<SearchResultItem[]>([]);
|
||||
const keyword = ref('');
|
||||
const retrievalMode = ref<RetrievalMode>('HYBRID');
|
||||
const isSearching = ref(false);
|
||||
const hasSearched = ref(false);
|
||||
const previewSearchKnowledgeRef = ref();
|
||||
const searchConfig = reactive({
|
||||
docRecallMaxNum: 5,
|
||||
simThreshold: 0.6,
|
||||
});
|
||||
|
||||
const retrievalModeDescriptions = computed(() => [
|
||||
{
|
||||
@@ -64,14 +81,65 @@ const retrievalModeDescriptions = computed(() => [
|
||||
},
|
||||
]);
|
||||
|
||||
const handleSearch = () => {
|
||||
if (!keyword.value) {
|
||||
const applySearchConfig = (options?: Record<string, any>) => {
|
||||
const rawRecallMax = Number(options?.docRecallMaxNum);
|
||||
const rawSimilarity = Number(options?.simThreshold);
|
||||
searchConfig.docRecallMaxNum =
|
||||
Number.isFinite(rawRecallMax) && rawRecallMax > 0 ? rawRecallMax : 5;
|
||||
searchConfig.simThreshold =
|
||||
Number.isFinite(rawSimilarity) && rawSimilarity >= 0 && rawSimilarity <= 1
|
||||
? rawSimilarity
|
||||
: 0.6;
|
||||
};
|
||||
|
||||
const loadSearchConfig = async () => {
|
||||
if (!props.showConfig || !props.knowledgeId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await props.requestClient.get(
|
||||
buildKnowledgePath(
|
||||
props.endpointPrefix,
|
||||
'/api/v1/documentCollection/detail',
|
||||
),
|
||||
{
|
||||
params: {
|
||||
id: props.knowledgeId,
|
||||
},
|
||||
},
|
||||
);
|
||||
applySearchConfig(res.data?.options);
|
||||
} catch {
|
||||
applySearchConfig();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadSearchConfig();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.knowledgeId,
|
||||
() => {
|
||||
loadSearchConfig();
|
||||
},
|
||||
);
|
||||
|
||||
const handleSearch = async () => {
|
||||
const normalizedKeyword = keyword.value.trim();
|
||||
if (!normalizedKeyword) {
|
||||
ElMessage.error($t('message.pleaseInputContent'));
|
||||
return;
|
||||
}
|
||||
previewSearchKnowledgeRef.value.loadingContent(true);
|
||||
props.requestClient
|
||||
.get(
|
||||
if (isSearching.value) {
|
||||
return;
|
||||
}
|
||||
keyword.value = normalizedKeyword;
|
||||
isSearching.value = true;
|
||||
hasSearched.value = true;
|
||||
previewSearchKnowledgeRef.value?.loadingContent(true);
|
||||
try {
|
||||
const res = await props.requestClient.get(
|
||||
buildKnowledgePath(
|
||||
props.endpointPrefix,
|
||||
'/api/v1/documentCollection/search',
|
||||
@@ -79,21 +147,21 @@ const handleSearch = () => {
|
||||
{
|
||||
params: {
|
||||
knowledgeId: props.knowledgeId,
|
||||
keyword: keyword.value,
|
||||
keyword: normalizedKeyword,
|
||||
retrievalMode: retrievalMode.value,
|
||||
docRecallMaxNum: searchConfig.docRecallMaxNum,
|
||||
simThreshold: searchConfig.simThreshold,
|
||||
},
|
||||
},
|
||||
)
|
||||
.then((res) => {
|
||||
searchDataList.value = res.data;
|
||||
})
|
||||
.catch(() => {
|
||||
ElMessage.error($t('documentCollection.searchFailed'));
|
||||
searchDataList.value = [];
|
||||
})
|
||||
.finally(() => {
|
||||
previewSearchKnowledgeRef.value.loadingContent(false);
|
||||
});
|
||||
);
|
||||
searchDataList.value = res.data || [];
|
||||
} catch {
|
||||
ElMessage.error($t('documentCollection.searchFailed'));
|
||||
searchDataList.value = [];
|
||||
} finally {
|
||||
isSearching.value = false;
|
||||
previewSearchKnowledgeRef.value?.loadingContent(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetrievalModeChange = (mode: RetrievalMode) => {
|
||||
@@ -102,41 +170,102 @@ const handleRetrievalModeChange = (mode: RetrievalMode) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="search-container">
|
||||
<div class="search-input">
|
||||
<ElInput
|
||||
v-model="keyword"
|
||||
:placeholder="$t('common.searchPlaceholder')"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<ElButton type="primary" @click="handleSearch">
|
||||
{{ $t('button.query') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<div class="knowledge-search-shell">
|
||||
<div class="knowledge-search-sidebar">
|
||||
<div class="search-controls">
|
||||
<div class="search-controls__search">
|
||||
<div class="search-input">
|
||||
<ElInput
|
||||
v-model="keyword"
|
||||
clearable
|
||||
:placeholder="$t('common.searchPlaceholder')"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:loading="isSearching"
|
||||
@click="handleSearch"
|
||||
>
|
||||
{{ $t('button.query') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search-hint">
|
||||
<div class="search-hint__title">
|
||||
{{ $t('documentCollectionSearch.retrievalModeTitle') }}
|
||||
</div>
|
||||
<div class="search-hint__list">
|
||||
<button
|
||||
v-for="item in retrievalModeDescriptions"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="search-hint__item"
|
||||
:class="{ 'is-active': retrievalMode === item.key }"
|
||||
@click="handleRetrievalModeChange(item.key)"
|
||||
>
|
||||
<div class="search-hint__label">{{ item.label }}</div>
|
||||
<div class="search-hint__desc">{{ item.description }}</div>
|
||||
</button>
|
||||
<div v-if="showConfig" class="search-controls__header">
|
||||
<div class="config-grid">
|
||||
<div class="config-item">
|
||||
<div class="config-item__label">
|
||||
<span>{{
|
||||
$t('documentCollectionSearch.docRecallMaxNum.label')
|
||||
}}</span>
|
||||
<ElTooltip
|
||||
:content="
|
||||
$t('documentCollectionSearch.docRecallMaxNum.tooltip')
|
||||
"
|
||||
placement="top"
|
||||
effect="dark"
|
||||
>
|
||||
<InfoFilled class="info-icon" />
|
||||
</ElTooltip>
|
||||
</div>
|
||||
<ElInputNumber
|
||||
v-model="searchConfig.docRecallMaxNum"
|
||||
:min="1"
|
||||
:max="50"
|
||||
:step="1"
|
||||
class="config-item__control"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="config-item">
|
||||
<div class="config-item__label">
|
||||
<span>{{
|
||||
$t('documentCollectionSearch.simThreshold.label')
|
||||
}}</span>
|
||||
<ElTooltip
|
||||
:content="$t('documentCollectionSearch.simThreshold.tooltip')"
|
||||
placement="top"
|
||||
effect="dark"
|
||||
>
|
||||
<InfoFilled class="info-icon" />
|
||||
</ElTooltip>
|
||||
</div>
|
||||
<ElInputNumber
|
||||
v-model="searchConfig.simThreshold"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
:precision="2"
|
||||
controls-position="right"
|
||||
class="config-item__control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search-controls__mode">
|
||||
<div class="search-hint__list">
|
||||
<button
|
||||
v-for="item in retrievalModeDescriptions"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="search-hint__item"
|
||||
:class="{ 'is-active': retrievalMode === item.key }"
|
||||
@click="handleRetrievalModeChange(item.key)"
|
||||
>
|
||||
<div class="search-hint__label">{{ item.label }}</div>
|
||||
<div class="search-hint__desc">{{ item.description }}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search-result">
|
||||
<div class="knowledge-search-preview">
|
||||
<PreviewSearchKnowledge
|
||||
ref="previewSearchKnowledgeRef"
|
||||
:data="searchDataList"
|
||||
:is-searching="hasSearched"
|
||||
:retrieval-mode="retrievalMode"
|
||||
/>
|
||||
</div>
|
||||
@@ -144,11 +273,81 @@ const handleRetrievalModeChange = (mode: RetrievalMode) => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-container {
|
||||
.knowledge-search-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(300px, 332px) minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.knowledge-search-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
align-self: start;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.search-controls {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 18px;
|
||||
background: var(--el-fill-color-blank);
|
||||
border: 1px solid rgb(15 23 42 / 6%);
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.search-controls__header {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-controls__search {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.search-controls__mode {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.config-item__label {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.config-item__control {
|
||||
width: 100%;
|
||||
padding: 0 0 20px;
|
||||
}
|
||||
|
||||
.config-item__control :deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
@@ -161,48 +360,34 @@ const handleRetrievalModeChange = (mode: RetrievalMode) => {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.search-hint {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
padding: 18px 20px;
|
||||
background: var(--el-fill-color-blank);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.search-hint__title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.search-hint__list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.search-hint__item {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
padding: 14px 16px;
|
||||
padding: 12px 14px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: var(--el-fill-color-light);
|
||||
border: 1px solid transparent;
|
||||
background: rgb(248 250 252 / 88%);
|
||||
border: 1px solid rgb(15 23 42 / 6%);
|
||||
border-radius: 14px;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.search-hint__item:hover {
|
||||
border-color: rgb(59 130 246 / 22%);
|
||||
background: rgb(248 250 252 / 100%);
|
||||
}
|
||||
|
||||
.search-hint__item.is-active {
|
||||
background: var(--el-color-primary-light-9);
|
||||
border-color: var(--el-color-primary-light-7);
|
||||
box-shadow: 0 6px 18px rgb(64 158 255 / 10%);
|
||||
background: rgb(239 246 255 / 92%);
|
||||
border-color: rgb(96 165 250 / 45%);
|
||||
}
|
||||
|
||||
.search-hint__item:focus-visible {
|
||||
@@ -222,17 +407,31 @@ const handleRetrievalModeChange = (mode: RetrievalMode) => {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.search-result {
|
||||
padding-top: 20px;
|
||||
.knowledge-search-preview {
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.search-hint__list {
|
||||
@media (max-width: 1024px) {
|
||||
.knowledge-search-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.knowledge-search-preview {
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-input :deep(.el-button) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.config-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -35,6 +35,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
manageable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['reload']);
|
||||
@@ -131,8 +135,16 @@ onMounted(() => {
|
||||
<div class="share-config-card__desc">
|
||||
仅开放模型切换、检索参数与向量重建
|
||||
</div>
|
||||
<div v-if="!props.manageable" class="share-config-card__tip">
|
||||
当前分享仅支持查看,不允许修改配置
|
||||
</div>
|
||||
</div>
|
||||
<ElButton type="primary" :loading="saving" @click="handleSave">
|
||||
<ElButton
|
||||
v-if="props.manageable"
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
@click="handleSave"
|
||||
>
|
||||
{{ $t('button.save') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
@@ -140,7 +152,7 @@ onMounted(() => {
|
||||
|
||||
<ElForm label-position="top" class="share-config-form">
|
||||
<ElFormItem :label="$t('documentCollection.vectorEmbedLlmId')">
|
||||
<ElSelect v-model="form.vectorEmbedModelId">
|
||||
<ElSelect v-model="form.vectorEmbedModelId" :disabled="!props.manageable">
|
||||
<ElOption
|
||||
v-for="item in embeddingModels"
|
||||
:key="item.id"
|
||||
@@ -151,7 +163,7 @@ onMounted(() => {
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem :label="$t('documentCollection.rerankLlmId')">
|
||||
<ElSelect v-model="form.rerankModelId" clearable>
|
||||
<ElSelect v-model="form.rerankModelId" clearable :disabled="!props.manageable">
|
||||
<ElOption
|
||||
v-for="item in rerankModels"
|
||||
:key="item.id"
|
||||
@@ -162,11 +174,16 @@ onMounted(() => {
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem :label="$t('documentCollection.rerankEnable')">
|
||||
<ElSwitch v-model="form.rerankEnable" />
|
||||
<ElSwitch v-model="form.rerankEnable" :disabled="!props.manageable" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem :label="$t('documentCollectionSearch.docRecallMaxNum.label')">
|
||||
<ElInputNumber v-model="form.docRecallMaxNum" :min="1" :max="50" />
|
||||
<ElInputNumber
|
||||
v-model="form.docRecallMaxNum"
|
||||
:min="1"
|
||||
:max="50"
|
||||
:disabled="!props.manageable"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem :label="$t('documentCollectionSearch.simThreshold.label')">
|
||||
@@ -175,11 +192,12 @@ onMounted(() => {
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
:disabled="!props.manageable"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="保存后重建向量">
|
||||
<ElSwitch v-model="form.rebuildVectors" />
|
||||
<ElSwitch v-model="form.rebuildVectors" :disabled="!props.manageable" />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</ElCard>
|
||||
@@ -208,6 +226,12 @@ onMounted(() => {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.share-config-card__tip {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.share-config-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
@@ -129,16 +129,37 @@ const endpointDocs = computed(() => {
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/document/import/commit',
|
||||
hint: '导入文档',
|
||||
path: '/document/import/task/create',
|
||||
hint: '创建导入任务',
|
||||
params: [
|
||||
{ name: 'knowledgeId', location: 'body', required: true },
|
||||
{
|
||||
name: 'previewSessionIds',
|
||||
name: 'fileName',
|
||||
location: 'body',
|
||||
required: true,
|
||||
note: '预览接口返回的会话 ID 数组',
|
||||
note: '文件名',
|
||||
},
|
||||
{ name: 'filePath', location: 'body', required: true, note: '上传后的文件路径' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/document/import/task/preview',
|
||||
hint: '生成分块预览',
|
||||
params: [
|
||||
{ name: 'knowledgeId', location: 'body', required: true },
|
||||
{ name: 'documentId', location: 'body', required: true },
|
||||
{ name: 'files[0].strategyConfig', location: 'body', note: '拆分策略配置' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/document/import/task/startIndex',
|
||||
hint: '启动向量化',
|
||||
params: [
|
||||
{ name: 'knowledgeId', location: 'body', required: true },
|
||||
{ name: 'documentId', location: 'body', required: true },
|
||||
{ name: 'previewSessionId', location: 'body', note: '预览接口返回的会话 ID' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -13,15 +13,23 @@ import FaqTable from '#/views/ai/documentCollection/FaqTable.vue';
|
||||
import ImportKnowledgeDocFile from '#/views/ai/documentCollection/ImportKnowledgeDocFile.vue';
|
||||
import KnowledgeSearch from '#/views/ai/documentCollection/KnowledgeSearch.vue';
|
||||
import KnowledgeShareConfigPanel from '#/views/ai/documentCollection/KnowledgeShareConfigPanel.vue';
|
||||
import SegmenterDoc from '#/views/ai/documentCollection/SegmenterDoc.vue';
|
||||
|
||||
interface KnowledgeShareViewDetail {
|
||||
knowledge?: Record<string, any>;
|
||||
permissionScopes?: string[];
|
||||
}
|
||||
|
||||
const endpointPrefix = '/api/v1/share/knowledge';
|
||||
const knowledgeInfo = ref<any>({});
|
||||
const permissionScopes = ref<string[]>([]);
|
||||
const loading = ref(false);
|
||||
const selectedCategory = ref('');
|
||||
const viewDocVisible = ref(false);
|
||||
const panelMode = ref<'chunk' | 'list' | 'process'>('list');
|
||||
const documentId = ref('');
|
||||
const importVisible = ref(false);
|
||||
const documentTitle = ref('');
|
||||
const documentTableRef = ref();
|
||||
const importDocModalRef = ref<InstanceType<typeof ImportKnowledgeDocFile>>();
|
||||
|
||||
const isFaqCollection = computed(
|
||||
() => knowledgeInfo.value.collectionType === 'FAQ',
|
||||
@@ -41,6 +49,31 @@ const knowledgeTitle = computed(
|
||||
const knowledgeDescription = computed(
|
||||
() => knowledgeInfo.value.description || '',
|
||||
);
|
||||
const permissionScopeSet = computed(
|
||||
() => new Set((permissionScopes.value || []).map((item) => String(item || '').toUpperCase())),
|
||||
);
|
||||
const canCreateContent = computed(() =>
|
||||
permissionScopeSet.value.has('CONTENT_CREATE'),
|
||||
);
|
||||
const canDeleteContent = computed(() =>
|
||||
permissionScopeSet.value.has('CONTENT_DELETE'),
|
||||
);
|
||||
const canUpdateContent = computed(() =>
|
||||
permissionScopeSet.value.has('CONTENT_UPDATE'),
|
||||
);
|
||||
const canUpdateConfig = computed(() =>
|
||||
permissionScopeSet.value.has('CONFIG_UPDATE'),
|
||||
);
|
||||
const canDownloadContent = computed(() =>
|
||||
permissionScopeSet.value.has('VIEW'),
|
||||
);
|
||||
const canManageContent = computed(
|
||||
() =>
|
||||
canCreateContent.value ||
|
||||
canDeleteContent.value ||
|
||||
canUpdateContent.value ||
|
||||
permissionScopeSet.value.has('IMPORT_EXPORT'),
|
||||
);
|
||||
const categoryData = computed(() => {
|
||||
if (isFaqCollection.value) {
|
||||
return [
|
||||
@@ -68,7 +101,9 @@ const loadKnowledge = async () => {
|
||||
const res = await knowledgeShareApi.get(
|
||||
`${endpointPrefix}/documentCollection/detail`,
|
||||
);
|
||||
knowledgeInfo.value = res.data || {};
|
||||
const detail = (res.data || {}) as KnowledgeShareViewDetail;
|
||||
knowledgeInfo.value = detail.knowledge || {};
|
||||
permissionScopes.value = detail.permissionScopes || [];
|
||||
selectedCategory.value =
|
||||
knowledgeInfo.value.collectionType === 'FAQ' ? 'faqList' : 'documentList';
|
||||
} finally {
|
||||
@@ -78,15 +113,28 @@ const loadKnowledge = async () => {
|
||||
|
||||
const handleViewDoc = (id: string) => {
|
||||
documentId.value = id;
|
||||
viewDocVisible.value = true;
|
||||
panelMode.value = 'chunk';
|
||||
};
|
||||
|
||||
const backToDocumentList = () => {
|
||||
viewDocVisible.value = false;
|
||||
panelMode.value = 'list';
|
||||
documentTitle.value = '';
|
||||
documentTableRef.value?.reload?.();
|
||||
};
|
||||
|
||||
const continueProcess = (doc: any) => {
|
||||
documentId.value = String(doc?.id || '');
|
||||
documentTitle.value = doc?.title || '';
|
||||
panelMode.value = 'process';
|
||||
};
|
||||
|
||||
const openImport = () => {
|
||||
importVisible.value = true;
|
||||
importDocModalRef.value?.openDialog?.();
|
||||
};
|
||||
|
||||
const handleCategoryClick = (key: string) => {
|
||||
selectedCategory.value = key;
|
||||
panelMode.value = 'list';
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
@@ -96,7 +144,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="share-page" v-loading="loading">
|
||||
<div v-if="!importVisible" class="share-shell">
|
||||
<div class="share-shell">
|
||||
<ElCard shadow="never" class="share-hero">
|
||||
<div class="share-hero__main">
|
||||
<div class="share-hero__meta">
|
||||
@@ -115,7 +163,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="share-hero__actions">
|
||||
<ElButton
|
||||
v-if="!isFaqCollection"
|
||||
v-if="!isFaqCollection && canCreateContent"
|
||||
type="primary"
|
||||
@click="openImport"
|
||||
>
|
||||
@@ -129,7 +177,7 @@ onMounted(() => {
|
||||
:key="item.key"
|
||||
class="share-tab"
|
||||
:class="{ 'is-active': selectedCategory === item.key }"
|
||||
@click="selectedCategory = item.key"
|
||||
@click="handleCategoryClick(item.key)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</button>
|
||||
@@ -139,22 +187,37 @@ onMounted(() => {
|
||||
<div class="share-body">
|
||||
<div v-if="selectedCategory === 'documentList'" class="share-panel">
|
||||
<DocumentTable
|
||||
v-if="!viewDocVisible"
|
||||
v-if="panelMode === 'list'"
|
||||
ref="documentTableRef"
|
||||
:knowledge-id="knowledgeInfo.id"
|
||||
:manageable="true"
|
||||
:permissions="{
|
||||
canCreateContent: canCreateContent,
|
||||
canDeleteContent: canDeleteContent,
|
||||
canDownloadContent: canDownloadContent,
|
||||
}"
|
||||
:request-client="knowledgeShareApi"
|
||||
:endpoint-prefix="endpointPrefix"
|
||||
@continue-process="continueProcess"
|
||||
@view-doc="handleViewDoc"
|
||||
/>
|
||||
<ChunkDocumentTable
|
||||
v-else
|
||||
v-else-if="panelMode === 'chunk'"
|
||||
:document-id="documentId"
|
||||
:manageable="true"
|
||||
:manageable="canManageContent"
|
||||
:request-client="knowledgeShareApi"
|
||||
:endpoint-prefix="endpointPrefix"
|
||||
/>
|
||||
<div v-if="viewDocVisible" class="share-panel__footer">
|
||||
<SegmenterDoc
|
||||
v-else-if="panelMode === 'process'"
|
||||
:knowledge-id="String(knowledgeInfo.id || '')"
|
||||
:document-id="documentId"
|
||||
:document-title="documentTitle"
|
||||
:request-client="knowledgeShareApi"
|
||||
:endpoint-prefix="endpointPrefix"
|
||||
@cancel="backToDocumentList"
|
||||
@started="backToDocumentList"
|
||||
/>
|
||||
<div v-if="panelMode === 'chunk'" class="share-panel__footer">
|
||||
<ElButton @click="backToDocumentList">
|
||||
{{ $t('button.back') }}
|
||||
</ElButton>
|
||||
@@ -164,7 +227,7 @@ onMounted(() => {
|
||||
<div v-if="selectedCategory === 'faqList'" class="share-panel">
|
||||
<FaqTable
|
||||
:knowledge-id="knowledgeInfo.id"
|
||||
:manageable="true"
|
||||
:manageable="canManageContent"
|
||||
:request-client="knowledgeShareApi"
|
||||
:endpoint-prefix="endpointPrefix"
|
||||
/>
|
||||
@@ -182,6 +245,7 @@ onMounted(() => {
|
||||
<KnowledgeShareConfigPanel
|
||||
:knowledge-id="String(knowledgeInfo.id || '')"
|
||||
:detail-data="knowledgeInfo"
|
||||
:manageable="canUpdateConfig"
|
||||
:request-client="knowledgeShareApi"
|
||||
:endpoint-prefix="endpointPrefix"
|
||||
@reload="loadKnowledge"
|
||||
@@ -191,11 +255,11 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<ImportKnowledgeDocFile
|
||||
v-else
|
||||
ref="importDocModalRef"
|
||||
:request-client="knowledgeShareApi"
|
||||
:endpoint-prefix="endpointPrefix"
|
||||
:knowledge-id-prop="String(knowledgeInfo.id || '')"
|
||||
@import-back="importVisible = false"
|
||||
@imported="backToDocumentList"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ref } from 'vue';
|
||||
import { $t } from '@easyflow/locales';
|
||||
|
||||
import { Document } from '@element-plus/icons-vue';
|
||||
import { ElButton, ElIcon, ElTag } from 'element-plus';
|
||||
import { ElButton, ElEmpty, ElIcon, ElTag } from 'element-plus';
|
||||
|
||||
type RetrievalMode = 'HYBRID' | 'KEYWORD' | 'VECTOR';
|
||||
type HitSource = 'BOTH' | 'KEYWORD' | 'VECTOR';
|
||||
@@ -100,6 +100,20 @@ const resolveHitSourceType = (hitSource?: HitSource) => {
|
||||
return 'info';
|
||||
};
|
||||
|
||||
const normalizePreviewContent = (content?: string) => {
|
||||
if (!content) {
|
||||
return '';
|
||||
}
|
||||
if (typeof window !== 'undefined' && typeof DOMParser !== 'undefined') {
|
||||
const doc = new DOMParser().parseFromString(content, 'text/html');
|
||||
return (doc.body.textContent || '').replaceAll(/\n\s*\n/g, '\n').trim();
|
||||
}
|
||||
return content
|
||||
.replaceAll(/<[^>]+>/g, ' ')
|
||||
.replaceAll(/\s+/g, ' ')
|
||||
.trim();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
loadingContent: (state: boolean) => {
|
||||
loadingStatus.value = state;
|
||||
@@ -109,7 +123,6 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<div class="preview-container" v-loading="loadingStatus">
|
||||
<!-- 头部区域:标题 + 统计信息 -->
|
||||
<div class="preview-header">
|
||||
<h3>
|
||||
<ElIcon class="header-icon" size="20">
|
||||
@@ -128,55 +141,59 @@ defineExpose({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域:列表预览 -->
|
||||
<div class="preview-content">
|
||||
<div class="preview-list">
|
||||
<div
|
||||
v-for="(item, index) in data"
|
||||
:key="index"
|
||||
class="el-list-item-container"
|
||||
>
|
||||
<div class="el-list-item">
|
||||
<div class="segment-badge">
|
||||
{{ item.sorting ?? index + 1 }}
|
||||
</div>
|
||||
<div class="el-list-item-meta">
|
||||
<div v-if="data.length > 0" class="preview-list">
|
||||
<div v-for="(item, index) in data" :key="index" class="preview-item">
|
||||
<div class="preview-item__header">
|
||||
<div class="preview-item__meta">
|
||||
<div class="segment-badge">
|
||||
{{ item.sorting ?? index + 1 }}
|
||||
</div>
|
||||
<div v-if="!hideScore" class="score-text">
|
||||
{{ $t('documentCollection.similarityScore') }}: {{ item.score }}
|
||||
</div>
|
||||
<div class="content-desc">{{ item.content }}</div>
|
||||
<div
|
||||
v-if="resolveDisplayHitSource(item.hitSource, retrievalMode)"
|
||||
class="hit-source-row"
|
||||
>
|
||||
<ElTag
|
||||
size="small"
|
||||
effect="plain"
|
||||
:type="
|
||||
resolveHitSourceType(
|
||||
resolveDisplayHitSource(item.hitSource, retrievalMode),
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
resolveHitSourceLabel(
|
||||
resolveDisplayHitSource(item.hitSource, retrievalMode),
|
||||
)
|
||||
}}
|
||||
</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="resolveDisplayHitSource(item.hitSource, retrievalMode)"
|
||||
class="hit-source-row"
|
||||
>
|
||||
<ElTag
|
||||
size="small"
|
||||
effect="plain"
|
||||
:type="
|
||||
resolveHitSourceType(
|
||||
resolveDisplayHitSource(item.hitSource, retrievalMode),
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
resolveHitSourceLabel(
|
||||
resolveDisplayHitSource(item.hitSource, retrievalMode),
|
||||
)
|
||||
}}
|
||||
</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-desc">
|
||||
{{ normalizePreviewContent(item.content) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ElEmpty
|
||||
v-else
|
||||
:description="
|
||||
isSearching
|
||||
? $t('documentCollection.searchResults')
|
||||
: $t('documentCollection.documentPreview')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮区域(仅导入确认模式显示) -->
|
||||
<div class="preview-actions" v-if="confirmImport">
|
||||
<div class="action-buttons">
|
||||
<ElButton
|
||||
:style="{ minWidth: '100px', height: '36px' }"
|
||||
click="onCancel"
|
||||
@click="onCancel"
|
||||
>
|
||||
{{ $t('documentCollection.actions.confirmImport') }}
|
||||
</ElButton>
|
||||
@@ -184,7 +201,7 @@ defineExpose({
|
||||
type="primary"
|
||||
:style="{ minWidth: '100px', height: '36px' }"
|
||||
:loading="disabledConfirm"
|
||||
click="onConfirm"
|
||||
@click="onConfirm"
|
||||
>
|
||||
{{ $t('documentCollection.actions.cancelImport') }}
|
||||
</ElButton>
|
||||
@@ -195,10 +212,14 @@ defineExpose({
|
||||
|
||||
<style scoped>
|
||||
.preview-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background-color: var(--el-bg-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px 0 rgb(0 0 0 / 8%);
|
||||
border: 1px solid rgb(15 23 42 / 6%);
|
||||
border-radius: 20px;
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
@@ -228,9 +249,15 @@ defineExpose({
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
padding: 20px;
|
||||
flex: 1;
|
||||
padding: 18px 20px;
|
||||
overflow: hidden auto;
|
||||
|
||||
.preview-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
|
||||
.segment-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -244,59 +271,45 @@ defineExpose({
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.similarity-score {
|
||||
font-size: 14px;
|
||||
color: var(--el-color-primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.content-desc {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
padding: 14px 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
background-color: var(--el-bg-color);
|
||||
border-left: 3px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #4361ee;
|
||||
box-shadow: 0 4px 12px rgb(67 97 238 / 8%);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
word-break: break-word;
|
||||
background: rgb(248 250 252 / 90%);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.score-text {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.preview-item {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.preview-item__header {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.preview-item__meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hit-source-row {
|
||||
display: flex;
|
||||
align-self: flex-end;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.el-list-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 18px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.el-list-item-meta {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -318,14 +331,4 @@ defineExpose({
|
||||
.el-list--loading .el-list-loading {
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.el-list-item {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,56 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue';
|
||||
import { reactive, ref, watch } from 'vue';
|
||||
|
||||
import { $t } from '@easyflow/locales';
|
||||
|
||||
import {
|
||||
ElAlert,
|
||||
ElCard,
|
||||
ElCol,
|
||||
ElButton,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElMessage,
|
||||
ElOption,
|
||||
ElRow,
|
||||
ElSelect,
|
||||
ElSlider,
|
||||
ElTag,
|
||||
} from 'element-plus';
|
||||
|
||||
interface StrategyConfig {
|
||||
chunkSize?: number;
|
||||
mdSplitterLevel?: number;
|
||||
overlapSize?: number;
|
||||
regex?: string;
|
||||
rowsPerChunk?: number;
|
||||
strategyCode?: string;
|
||||
import { api } from '#/api/request';
|
||||
import { buildKnowledgePath } from '#/views/ai/documentCollection/share-path';
|
||||
import SplitterDocPreview from '#/views/ai/documentCollection/SplitterDocPreview.vue';
|
||||
|
||||
interface SourceRange {
|
||||
end: number;
|
||||
start: number;
|
||||
}
|
||||
|
||||
interface StrategyCandidate {
|
||||
score?: number;
|
||||
strategyCode: string;
|
||||
strategyLabel: string;
|
||||
interface ChunkItem {
|
||||
answer?: string;
|
||||
charCount?: number;
|
||||
chunkId?: string;
|
||||
chunkType?: string;
|
||||
content?: string;
|
||||
headingPath?: string[];
|
||||
options?: Record<string, any>;
|
||||
partNo?: number;
|
||||
partTotal?: number;
|
||||
question?: string;
|
||||
sourceLabel?: string;
|
||||
sourceRanges?: SourceRange[];
|
||||
tokenEstimate?: number;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
interface AnalysisResult {
|
||||
candidateStrategies?: StrategyCandidate[];
|
||||
confidence?: number;
|
||||
reasons?: string[];
|
||||
recommendedStrategyCode?: string;
|
||||
recommendedStrategyLabel?: string;
|
||||
recommendedStructureType?: string;
|
||||
}
|
||||
|
||||
interface AnalyzeItem {
|
||||
analysis?: AnalysisResult;
|
||||
interface PreviewItem {
|
||||
analysis?: {
|
||||
normalizedContent?: string;
|
||||
recommendedStrategyLabel?: string;
|
||||
};
|
||||
chunks?: ChunkItem[];
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
strategyConfig?: StrategyConfig;
|
||||
normalizedContent?: string;
|
||||
previewSessionId: string;
|
||||
strategyCode?: string;
|
||||
strategyLabel?: string;
|
||||
totalChunks?: number;
|
||||
totalWarnings?: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
analysisItems?: AnalyzeItem[];
|
||||
}>();
|
||||
const props = defineProps({
|
||||
documentId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
documentTitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
endpointPrefix: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
knowledgeId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
requestClient: {
|
||||
type: Object as any,
|
||||
default: () => api,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['cancel', 'started']);
|
||||
|
||||
const createDefaultFormState = () => ({
|
||||
chunkSize: 512,
|
||||
mdSplitterLevel: 2,
|
||||
overlapSize: 128,
|
||||
regex: '',
|
||||
rowsPerChunk: 1,
|
||||
strategyCode: 'AUTO',
|
||||
});
|
||||
|
||||
const formState = reactive(createDefaultFormState());
|
||||
const previewItems = ref<PreviewItem[]>([]);
|
||||
const currentPreviewSessionId = ref('');
|
||||
const activeDocumentId = ref(props.documentId || '');
|
||||
const previewError = ref('');
|
||||
const previewLoading = ref(false);
|
||||
const startLoading = ref(false);
|
||||
let previewDebounceTimer: null | ReturnType<typeof setTimeout> = null;
|
||||
let previewSequence = 0;
|
||||
|
||||
const strategyOptions = [
|
||||
{
|
||||
@@ -81,313 +128,379 @@ const strategyOptions = [
|
||||
|
||||
const mdLevels = [1, 2, 3, 4, 5, 6];
|
||||
|
||||
const formMap = reactive<Record<string, StrategyConfig>>({});
|
||||
const showLengthSettings = (strategyCode?: string) =>
|
||||
['AUTO', 'MARKDOWN_SECTION', 'OUTLINE_SECTION', 'PARAGRAPH_LENGTH'].includes(
|
||||
strategyCode || '',
|
||||
);
|
||||
|
||||
function createDefaultStrategyConfig(item?: AnalyzeItem): StrategyConfig {
|
||||
return {
|
||||
chunkSize: item?.strategyConfig?.chunkSize ?? 512,
|
||||
mdSplitterLevel: item?.strategyConfig?.mdSplitterLevel ?? 2,
|
||||
overlapSize: item?.strategyConfig?.overlapSize ?? 128,
|
||||
regex: item?.strategyConfig?.regex ?? '',
|
||||
rowsPerChunk: item?.strategyConfig?.rowsPerChunk ?? 1,
|
||||
strategyCode:
|
||||
item?.strategyConfig?.strategyCode ||
|
||||
item?.analysis?.recommendedStrategyCode ||
|
||||
'AUTO',
|
||||
};
|
||||
}
|
||||
const showOverlapSettings = (strategyCode?: string) =>
|
||||
['AUTO', 'PARAGRAPH_LENGTH'].includes(strategyCode || '');
|
||||
|
||||
function getStrategyForm(filePath: string, item?: AnalyzeItem): StrategyConfig {
|
||||
if (!formMap[filePath]) {
|
||||
formMap[filePath] = createDefaultStrategyConfig(item);
|
||||
const clearPreviewTimer = () => {
|
||||
if (!previewDebounceTimer) {
|
||||
return;
|
||||
}
|
||||
return formMap[filePath]!;
|
||||
}
|
||||
clearTimeout(previewDebounceTimer);
|
||||
previewDebounceTimer = null;
|
||||
};
|
||||
|
||||
const resetPreviewState = () => {
|
||||
previewItems.value = [];
|
||||
currentPreviewSessionId.value = '';
|
||||
previewError.value = '';
|
||||
};
|
||||
|
||||
const buildStrategyConfig = () => ({
|
||||
...formState,
|
||||
});
|
||||
|
||||
const normalizeSourceRanges = (ranges?: SourceRange[]) =>
|
||||
Array.isArray(ranges)
|
||||
? ranges.filter(
|
||||
(item) =>
|
||||
Number.isFinite(item?.start) &&
|
||||
Number.isFinite(item?.end) &&
|
||||
Number(item.end) > Number(item.start),
|
||||
)
|
||||
: [];
|
||||
|
||||
const normalizePreviewItems = (items: PreviewItem[]) =>
|
||||
(items || []).map((item) => ({
|
||||
...item,
|
||||
normalizedContent:
|
||||
item.normalizedContent || item.analysis?.normalizedContent || '',
|
||||
strategyLabel:
|
||||
item.strategyLabel || item.analysis?.recommendedStrategyLabel || '',
|
||||
totalChunks:
|
||||
Number(item.totalChunks || 0) > 0
|
||||
? item.totalChunks
|
||||
: (item.chunks || []).length,
|
||||
chunks: (item.chunks || []).map((chunk) => ({
|
||||
...chunk,
|
||||
sourceRanges: normalizeSourceRanges(
|
||||
chunk.sourceRanges ||
|
||||
(Array.isArray(chunk.options?.sourceRanges)
|
||||
? chunk.options?.sourceRanges
|
||||
: []),
|
||||
),
|
||||
})),
|
||||
}));
|
||||
|
||||
const generatePreview = async () => {
|
||||
if (!activeDocumentId.value) {
|
||||
return;
|
||||
}
|
||||
const requestSequence = ++previewSequence;
|
||||
previewLoading.value = true;
|
||||
previewError.value = '';
|
||||
try {
|
||||
const res = await props.requestClient.post(
|
||||
buildKnowledgePath(
|
||||
props.endpointPrefix,
|
||||
'/api/v1/document/import/task/preview',
|
||||
),
|
||||
{
|
||||
documentId: activeDocumentId.value,
|
||||
files: [
|
||||
{
|
||||
strategyConfig: buildStrategyConfig(),
|
||||
},
|
||||
],
|
||||
knowledgeId: props.knowledgeId,
|
||||
},
|
||||
);
|
||||
if (requestSequence !== previewSequence) {
|
||||
return;
|
||||
}
|
||||
const items = normalizePreviewItems(
|
||||
(res.data?.items || []) as PreviewItem[],
|
||||
);
|
||||
previewItems.value = items;
|
||||
currentPreviewSessionId.value = items[0]?.previewSessionId || '';
|
||||
previewError.value = '';
|
||||
} catch (error: any) {
|
||||
if (requestSequence !== previewSequence) {
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
error?.message || $t('documentCollection.importDoc.previewRequestFailed');
|
||||
previewItems.value = [];
|
||||
currentPreviewSessionId.value = '';
|
||||
previewError.value = message;
|
||||
ElMessage.error(message);
|
||||
} finally {
|
||||
if (requestSequence === previewSequence) {
|
||||
previewLoading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const schedulePreviewGeneration = () => {
|
||||
if (!activeDocumentId.value) {
|
||||
return;
|
||||
}
|
||||
clearPreviewTimer();
|
||||
resetPreviewState();
|
||||
previewDebounceTimer = setTimeout(() => {
|
||||
previewDebounceTimer = null;
|
||||
void generatePreview();
|
||||
}, 320);
|
||||
};
|
||||
|
||||
const handlePreviewSessionChange = (previewSessionId: string) => {
|
||||
currentPreviewSessionId.value = previewSessionId;
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
previewSequence += 1;
|
||||
clearPreviewTimer();
|
||||
previewLoading.value = false;
|
||||
emits('cancel');
|
||||
};
|
||||
|
||||
const startIndex = async () => {
|
||||
if (!currentPreviewSessionId.value) {
|
||||
ElMessage.warning($t('documentCollection.importDoc.previewEmpty'));
|
||||
return;
|
||||
}
|
||||
startLoading.value = true;
|
||||
try {
|
||||
const res = await props.requestClient.post(
|
||||
buildKnowledgePath(
|
||||
props.endpointPrefix,
|
||||
'/api/v1/document/import/task/startIndex',
|
||||
),
|
||||
{
|
||||
documentId: activeDocumentId.value,
|
||||
knowledgeId: props.knowledgeId,
|
||||
previewSessionId: currentPreviewSessionId.value,
|
||||
},
|
||||
);
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success($t('documentCollection.importDoc.indexQueued'));
|
||||
emits('started');
|
||||
}
|
||||
} finally {
|
||||
startLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.analysisItems,
|
||||
(items) => {
|
||||
for (const item of items || []) {
|
||||
formMap[item.filePath] = createDefaultStrategyConfig(item);
|
||||
() => props.documentId,
|
||||
(value) => {
|
||||
activeDocumentId.value = value || '';
|
||||
previewSequence += 1;
|
||||
clearPreviewTimer();
|
||||
Object.assign(formState, createDefaultFormState());
|
||||
resetPreviewState();
|
||||
if (activeDocumentId.value) {
|
||||
schedulePreviewGeneration();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const items = computed(() => props.analysisItems ?? []);
|
||||
|
||||
defineExpose({
|
||||
getPreviewRequestItems() {
|
||||
return items.value.map((item) => ({
|
||||
fileName: item.fileName,
|
||||
filePath: item.filePath,
|
||||
strategyConfig: {
|
||||
...getStrategyForm(item.filePath, item),
|
||||
},
|
||||
}));
|
||||
watch(
|
||||
formState,
|
||||
() => {
|
||||
if (!activeDocumentId.value) {
|
||||
return;
|
||||
}
|
||||
schedulePreviewGeneration();
|
||||
},
|
||||
});
|
||||
|
||||
function showLengthSettings(strategyCode?: string) {
|
||||
return [
|
||||
'AUTO',
|
||||
'MARKDOWN_SECTION',
|
||||
'OUTLINE_SECTION',
|
||||
'PARAGRAPH_LENGTH',
|
||||
].includes(strategyCode || '');
|
||||
}
|
||||
{ deep: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="strategy-container">
|
||||
<ElAlert
|
||||
:title="$t('documentCollection.importDoc.analysisTip')"
|
||||
type="info"
|
||||
:closable="false"
|
||||
class="strategy-tip"
|
||||
/>
|
||||
<div class="workbench">
|
||||
<ElForm :model="formState" label-position="top" class="workbench__form">
|
||||
<div class="workbench__form-grid">
|
||||
<ElFormItem
|
||||
:label="$t('documentCollection.importDoc.strategySelection')"
|
||||
class="workbench__form-full"
|
||||
>
|
||||
<ElSelect v-model="formState.strategyCode" class="w-full">
|
||||
<ElOption
|
||||
v-for="option in strategyOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<div class="strategy-list">
|
||||
<ElCard
|
||||
v-for="item in items"
|
||||
:key="item.filePath"
|
||||
class="strategy-card"
|
||||
shadow="never"
|
||||
<ElFormItem
|
||||
v-if="showLengthSettings(formState.strategyCode)"
|
||||
:label="$t('documentCollection.splitterDoc.chunkSize')"
|
||||
:class="
|
||||
showOverlapSettings(formState.strategyCode)
|
||||
? ''
|
||||
: 'workbench__form-full'
|
||||
"
|
||||
>
|
||||
<ElSlider
|
||||
v-model="formState.chunkSize"
|
||||
:max="2048"
|
||||
:min="128"
|
||||
show-input
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem
|
||||
v-if="showOverlapSettings(formState.strategyCode)"
|
||||
:label="$t('documentCollection.splitterDoc.overlapSize')"
|
||||
>
|
||||
<ElSlider
|
||||
v-model="formState.overlapSize"
|
||||
:max="512"
|
||||
:min="0"
|
||||
show-input
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem
|
||||
v-if="formState.strategyCode === 'MARKDOWN_SECTION'"
|
||||
:label="$t('documentCollection.splitterDoc.mdSplitterLevel')"
|
||||
class="workbench__form-full"
|
||||
>
|
||||
<ElSelect v-model="formState.mdSplitterLevel" class="w-full">
|
||||
<ElOption
|
||||
v-for="level in mdLevels"
|
||||
:key="level"
|
||||
:label="'#'.repeat(level)"
|
||||
:value="level"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem
|
||||
v-if="formState.strategyCode === 'CUSTOM_REGEX'"
|
||||
:label="$t('documentCollection.splitterDoc.regex')"
|
||||
class="workbench__form-full"
|
||||
>
|
||||
<ElInput v-model="formState.regex" />
|
||||
</ElFormItem>
|
||||
</div>
|
||||
</ElForm>
|
||||
|
||||
<section class="workbench__content">
|
||||
<SplitterDocPreview
|
||||
:loading="previewLoading"
|
||||
:preview-items="previewItems"
|
||||
@preview-session-change="handlePreviewSessionChange"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div v-if="previewError" class="workbench__error">
|
||||
{{ previewError }}
|
||||
</div>
|
||||
|
||||
<div class="workbench__footer">
|
||||
<ElButton :disabled="startLoading" @click="handleCancel">
|
||||
{{ $t('button.cancel') }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:disabled="previewLoading || !currentPreviewSessionId"
|
||||
:loading="startLoading"
|
||||
@click="startIndex"
|
||||
>
|
||||
<div class="strategy-card__header">
|
||||
<div>
|
||||
<div class="strategy-card__title">{{ item.fileName }}</div>
|
||||
<div class="strategy-card__meta">
|
||||
{{ item.analysis?.recommendedStructureType || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="strategy-card__badges">
|
||||
<ElTag type="success" effect="plain">
|
||||
{{
|
||||
item.analysis?.recommendedStrategyLabel ||
|
||||
$t('documentCollection.splitterDoc.autoStrategy')
|
||||
}}
|
||||
</ElTag>
|
||||
<ElTag effect="plain">
|
||||
{{ $t('documentCollection.importDoc.confidence') }}
|
||||
{{ item.analysis?.confidence ?? 0 }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElRow :gutter="16" class="strategy-card__content">
|
||||
<ElCol :span="12">
|
||||
<div class="strategy-block">
|
||||
<div class="strategy-block__label">
|
||||
{{ $t('documentCollection.importDoc.recommendReason') }}
|
||||
</div>
|
||||
<ul class="strategy-reason-list">
|
||||
<li
|
||||
v-for="reason in item.analysis?.reasons || []"
|
||||
:key="reason"
|
||||
class="strategy-reason-list__item"
|
||||
>
|
||||
{{ reason }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="strategy-block">
|
||||
<div class="strategy-block__label">
|
||||
{{ $t('documentCollection.importDoc.candidateStrategies') }}
|
||||
</div>
|
||||
<div class="strategy-candidate-list">
|
||||
<ElTag
|
||||
v-for="candidate in item.analysis?.candidateStrategies || []"
|
||||
:key="candidate.strategyCode"
|
||||
effect="plain"
|
||||
>
|
||||
{{ candidate.strategyLabel }} / {{ candidate.score }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
</ElCol>
|
||||
|
||||
<ElCol :span="12">
|
||||
<ElForm
|
||||
:model="getStrategyForm(item.filePath, item)"
|
||||
label-position="top"
|
||||
class="strategy-form"
|
||||
>
|
||||
<ElFormItem
|
||||
:label="$t('documentCollection.importDoc.strategySelection')"
|
||||
>
|
||||
<ElSelect
|
||||
v-model="getStrategyForm(item.filePath, item).strategyCode"
|
||||
class="w-full"
|
||||
>
|
||||
<ElOption
|
||||
v-for="option in strategyOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem
|
||||
v-if="
|
||||
showLengthSettings(
|
||||
getStrategyForm(item.filePath, item).strategyCode,
|
||||
)
|
||||
"
|
||||
:label="$t('documentCollection.splitterDoc.chunkSize')"
|
||||
>
|
||||
<ElSlider
|
||||
v-model="getStrategyForm(item.filePath, item).chunkSize"
|
||||
:max="2048"
|
||||
:min="128"
|
||||
show-input
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem
|
||||
v-if="
|
||||
getStrategyForm(item.filePath, item).strategyCode ===
|
||||
'PARAGRAPH_LENGTH' ||
|
||||
getStrategyForm(item.filePath, item).strategyCode === 'AUTO'
|
||||
"
|
||||
:label="$t('documentCollection.splitterDoc.overlapSize')"
|
||||
>
|
||||
<ElSlider
|
||||
v-model="getStrategyForm(item.filePath, item).overlapSize"
|
||||
:max="512"
|
||||
:min="0"
|
||||
show-input
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem
|
||||
v-if="
|
||||
getStrategyForm(item.filePath, item).strategyCode ===
|
||||
'MARKDOWN_SECTION'
|
||||
"
|
||||
:label="$t('documentCollection.splitterDoc.mdSplitterLevel')"
|
||||
>
|
||||
<ElSelect
|
||||
v-model="getStrategyForm(item.filePath, item).mdSplitterLevel"
|
||||
class="w-full"
|
||||
>
|
||||
<ElOption
|
||||
v-for="level in mdLevels"
|
||||
:key="level"
|
||||
:label="'#'.repeat(level)"
|
||||
:value="level"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem
|
||||
v-if="
|
||||
getStrategyForm(item.filePath, item).strategyCode ===
|
||||
'CUSTOM_REGEX'
|
||||
"
|
||||
:label="$t('documentCollection.splitterDoc.regex')"
|
||||
>
|
||||
<ElInput v-model="getStrategyForm(item.filePath, item).regex" />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElCard>
|
||||
{{ $t('button.startIndex') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.strategy-container {
|
||||
.workbench {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 20px;
|
||||
min-height: 100%;
|
||||
padding: 4px 4px 0;
|
||||
}
|
||||
|
||||
.strategy-tip {
|
||||
border-radius: 12px;
|
||||
.workbench__form {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.strategy-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
.workbench__form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px 18px;
|
||||
}
|
||||
|
||||
.strategy-card {
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
.workbench__form-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.workbench__content {
|
||||
min-height: 620px;
|
||||
}
|
||||
|
||||
.workbench__error {
|
||||
padding: 14px 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--el-color-danger-dark-2);
|
||||
background: color-mix(in srgb, var(--el-color-danger-light-9) 88%, white);
|
||||
border: 1px solid color-mix(in srgb, var(--el-color-danger) 14%, white);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.strategy-card__header {
|
||||
.workbench__footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 0 8px;
|
||||
margin-top: auto;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgb(246 248 251 / 0%) 0%,
|
||||
rgb(246 248 251 / 82%) 30%,
|
||||
rgb(246 248 251 / 100%) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.strategy-card__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.strategy-card__meta {
|
||||
margin-top: 4px;
|
||||
:deep(.el-form-item__label) {
|
||||
padding-bottom: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.strategy-card__badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
:deep(.workbench__form .el-input__wrapper),
|
||||
:deep(.workbench__form .el-select__wrapper) {
|
||||
box-shadow: 0 0 0 1px rgb(15 23 42 / 7%) inset;
|
||||
}
|
||||
|
||||
.strategy-card__content {
|
||||
margin-top: 16px;
|
||||
:deep(.workbench__form .el-slider__runway) {
|
||||
background: rgb(15 23 42 / 8%);
|
||||
}
|
||||
|
||||
.strategy-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
@media (max-width: 1180px) {
|
||||
.workbench__content {
|
||||
min-height: 520px;
|
||||
}
|
||||
}
|
||||
|
||||
.strategy-block + .strategy-block {
|
||||
margin-top: 16px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.workbench {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.strategy-block__label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.workbench__header,
|
||||
.workbench__form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.strategy-reason-list {
|
||||
padding-left: 18px;
|
||||
margin: 0;
|
||||
line-height: 1.7;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.strategy-reason-list__item {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.strategy-candidate-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.strategy-form {
|
||||
padding: 16px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 12px;
|
||||
.workbench__form,
|
||||
.workbench__content {
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,15 +3,12 @@ import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { $t } from '@easyflow/locales';
|
||||
|
||||
import {
|
||||
ElAlert,
|
||||
ElDescriptions,
|
||||
ElDescriptionsItem,
|
||||
ElEmpty,
|
||||
ElTabPane,
|
||||
ElTabs,
|
||||
ElTag,
|
||||
} from 'element-plus';
|
||||
import { ElEmpty, ElSkeleton, ElTag } from 'element-plus';
|
||||
|
||||
interface SourceRange {
|
||||
end: number;
|
||||
start: number;
|
||||
}
|
||||
|
||||
interface ChunkItem {
|
||||
answer?: string;
|
||||
@@ -20,30 +17,46 @@ interface ChunkItem {
|
||||
chunkType?: string;
|
||||
content?: string;
|
||||
headingPath?: string[];
|
||||
options?: Record<string, any>;
|
||||
partNo?: number;
|
||||
partTotal?: number;
|
||||
question?: string;
|
||||
sourceLabel?: string;
|
||||
sourceRanges?: SourceRange[];
|
||||
tokenEstimate?: number;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
interface PreviewItem {
|
||||
analysis?: {
|
||||
confidence?: number;
|
||||
recommendedStructureType?: string;
|
||||
normalizedContent?: string;
|
||||
recommendedStrategyLabel?: string;
|
||||
};
|
||||
chunks?: ChunkItem[];
|
||||
fileName: string;
|
||||
normalizedContent?: string;
|
||||
previewSessionId: string;
|
||||
strategyLabel?: string;
|
||||
totalChunks?: number;
|
||||
totalWarnings?: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
previewItems?: PreviewItem[];
|
||||
}>();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
activeChunkId?: string;
|
||||
loading?: boolean;
|
||||
lockedChunkId?: string;
|
||||
previewItems?: PreviewItem[];
|
||||
}>(),
|
||||
{
|
||||
activeChunkId: '',
|
||||
loading: false,
|
||||
lockedChunkId: '',
|
||||
previewItems: () => [],
|
||||
},
|
||||
);
|
||||
|
||||
const emits = defineEmits(['previewSessionChange']);
|
||||
|
||||
const activeFile = ref('');
|
||||
|
||||
@@ -60,6 +73,7 @@ watch(
|
||||
(items) => {
|
||||
if (items.length === 0) {
|
||||
activeFile.value = '';
|
||||
emits('previewSessionChange', '');
|
||||
return;
|
||||
}
|
||||
if (!items.some((item) => item.previewSessionId === activeFile.value)) {
|
||||
@@ -68,112 +82,128 @@ watch(
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
currentPreview,
|
||||
(preview) => {
|
||||
emits('previewSessionChange', preview?.previewSessionId || '');
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const isLockedChunk = (chunk: ChunkItem) =>
|
||||
String(chunk.chunkId || '') === String(props.lockedChunkId || '');
|
||||
|
||||
const isActiveChunk = (chunk: ChunkItem) =>
|
||||
String(chunk.chunkId || '') === String(props.activeChunkId || '');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="preview-shell">
|
||||
<ElAlert
|
||||
:title="$t('documentCollection.importDoc.previewTip')"
|
||||
type="info"
|
||||
:closable="false"
|
||||
class="preview-alert"
|
||||
/>
|
||||
<div class="preview-shell__header">
|
||||
<div>
|
||||
<div class="preview-shell__title">
|
||||
{{ $t('documentCollection.importDoc.previewPaneTitle') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="currentPreview" class="preview-shell__summary">
|
||||
<ElTag effect="plain" round>
|
||||
{{ currentPreview.strategyLabel || '-' }}
|
||||
</ElTag>
|
||||
<ElTag effect="plain" round>
|
||||
{{ currentPreview.totalChunks || 0 }}
|
||||
{{ $t('documentCollection.importDoc.chunkCount') }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loading && previewItems.length === 0"
|
||||
class="preview-shell__loading"
|
||||
>
|
||||
<ElSkeleton animated :rows="7" />
|
||||
</div>
|
||||
|
||||
<ElEmpty
|
||||
v-if="previewItems.length === 0"
|
||||
v-else-if="previewItems.length === 0"
|
||||
:description="$t('documentCollection.importDoc.previewEmpty')"
|
||||
/>
|
||||
|
||||
<div v-else class="preview-panel">
|
||||
<ElTabs v-model="activeFile" class="preview-tabs">
|
||||
<ElTabPane
|
||||
v-for="item in previewItems"
|
||||
:key="item.previewSessionId"
|
||||
:label="item.fileName"
|
||||
:name="item.previewSessionId"
|
||||
/>
|
||||
</ElTabs>
|
||||
|
||||
<div v-if="currentPreview" class="preview-detail">
|
||||
<ElDescriptions :column="4" border class="preview-summary">
|
||||
<ElDescriptionsItem :label="$t('documentCollection.fileName')">
|
||||
{{ currentPreview.fileName }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem
|
||||
:label="$t('documentCollection.importDoc.strategySelection')"
|
||||
>
|
||||
{{ currentPreview.strategyLabel || '-' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="$t('documentCollection.total')">
|
||||
{{ currentPreview.totalChunks || 0 }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem
|
||||
:label="$t('documentCollection.importDoc.warningCount')"
|
||||
>
|
||||
{{ currentPreview.totalWarnings || 0 }}
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
|
||||
<div class="chunk-list">
|
||||
<div
|
||||
v-for="chunk in currentPreview.chunks || []"
|
||||
:key="chunk.chunkId"
|
||||
class="chunk-card"
|
||||
>
|
||||
<div class="chunk-card__header">
|
||||
<div>
|
||||
<div class="chunk-card__title">
|
||||
<div v-else class="preview-shell__content">
|
||||
<div class="chunk-list">
|
||||
<div
|
||||
v-for="chunk in currentPreview?.chunks || []"
|
||||
:key="chunk.chunkId"
|
||||
class="chunk-card"
|
||||
:class="{
|
||||
'chunk-card--active': isActiveChunk(chunk),
|
||||
'chunk-card--locked': isLockedChunk(chunk),
|
||||
}"
|
||||
>
|
||||
<div class="chunk-card__header">
|
||||
<div class="chunk-card__main">
|
||||
<div class="chunk-card__title-row">
|
||||
<span class="chunk-card__title">
|
||||
{{ chunk.sourceLabel || chunk.chunkId }}
|
||||
</div>
|
||||
<div
|
||||
v-if="chunk.headingPath && chunk.headingPath.length > 0"
|
||||
class="chunk-card__path"
|
||||
>
|
||||
{{ chunk.headingPath.join(' / ') }}
|
||||
</div>
|
||||
</span>
|
||||
<span v-if="isLockedChunk(chunk)" class="chunk-card__state">
|
||||
{{ $t('documentCollection.importDoc.lockedState') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="chunk-card__meta">
|
||||
<ElTag effect="plain">{{ chunk.chunkType || '-' }}</ElTag>
|
||||
<ElTag effect="plain">
|
||||
{{ chunk.charCount || 0 }} / {{ chunk.tokenEstimate || 0 }}
|
||||
</ElTag>
|
||||
<ElTag
|
||||
v-if="(chunk.partTotal || 1) > 1"
|
||||
type="warning"
|
||||
effect="plain"
|
||||
>
|
||||
{{ chunk.partNo }}/{{ chunk.partTotal }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="chunk.chunkType === 'qa_pair'" class="qa-block">
|
||||
<div class="qa-block__item">
|
||||
<span class="qa-block__label">Q</span>
|
||||
<span>{{ chunk.question }}</span>
|
||||
</div>
|
||||
<div class="qa-block__item">
|
||||
<span class="qa-block__label">A</span>
|
||||
<span>{{ chunk.answer }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pre class="chunk-card__content">{{ chunk.content }}</pre>
|
||||
|
||||
<div
|
||||
v-if="chunk.warnings && chunk.warnings.length > 0"
|
||||
class="chunk-card__warnings"
|
||||
>
|
||||
<ElTag
|
||||
v-for="warning in chunk.warnings"
|
||||
:key="warning"
|
||||
type="warning"
|
||||
effect="plain"
|
||||
<div
|
||||
v-if="chunk.headingPath && chunk.headingPath.length > 0"
|
||||
class="chunk-card__path"
|
||||
>
|
||||
{{ warning }}
|
||||
{{ chunk.headingPath.join(' / ') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chunk-card__meta">
|
||||
<ElTag effect="plain" round>
|
||||
{{ chunk.chunkType || '-' }}
|
||||
</ElTag>
|
||||
<ElTag effect="plain" round>
|
||||
{{ chunk.charCount || 0 }} / {{ chunk.tokenEstimate || 0 }}
|
||||
</ElTag>
|
||||
<ElTag
|
||||
v-if="(chunk.partTotal || 1) > 1"
|
||||
effect="plain"
|
||||
round
|
||||
type="warning"
|
||||
>
|
||||
{{ chunk.partNo }}/{{ chunk.partTotal }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="chunk.chunkType === 'qa_pair'" class="qa-block">
|
||||
<div class="qa-block__item">
|
||||
<span class="qa-block__label">Q</span>
|
||||
<span class="qa-block__text">{{ chunk.question }}</span>
|
||||
</div>
|
||||
<div class="qa-block__item">
|
||||
<span class="qa-block__label">A</span>
|
||||
<span class="qa-block__text">{{ chunk.answer }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pre class="chunk-card__content">{{ chunk.content }}</pre>
|
||||
|
||||
<div
|
||||
v-if="chunk.warnings && chunk.warnings.length > 0"
|
||||
class="chunk-card__warnings"
|
||||
>
|
||||
<ElTag
|
||||
v-for="warning in chunk.warnings"
|
||||
:key="warning"
|
||||
effect="plain"
|
||||
round
|
||||
type="warning"
|
||||
>
|
||||
{{ warning }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,55 +214,122 @@ watch(
|
||||
.preview-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.preview-alert {
|
||||
border-radius: 12px;
|
||||
.preview-shell__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.preview-panel {
|
||||
padding: 20px;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 16px;
|
||||
.preview-shell__title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.preview-summary {
|
||||
margin-bottom: 20px;
|
||||
.preview-shell__summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.preview-shell__loading {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.preview-shell__content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.chunk-list {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-height: 560px;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
padding-right: 4px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.chunk-card {
|
||||
padding: 16px;
|
||||
background: var(--el-fill-color-blank);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
padding: 15px 16px 14px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background: color-mix(in srgb, var(--el-fill-color-blank) 92%, white);
|
||||
border: 1px solid rgb(15 23 42 / 7%);
|
||||
border-radius: 14px;
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
background-color 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.chunk-card:hover {
|
||||
background: var(--el-fill-color-blank);
|
||||
border-color: rgb(11 111 211 / 22%);
|
||||
box-shadow: 0 10px 20px rgb(15 23 42 / 6%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.chunk-card--active {
|
||||
border-color: rgb(22 111 211 / 26%);
|
||||
background: linear-gradient(180deg, rgb(242 248 255 / 96%), #fff 100%);
|
||||
}
|
||||
|
||||
.chunk-card--locked {
|
||||
border-color: rgb(11 111 211 / 34%);
|
||||
box-shadow: inset 0 0 0 1px rgb(11 111 211 / 10%);
|
||||
}
|
||||
|
||||
.chunk-card__header {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.chunk-card__main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chunk-card__title-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chunk-card__title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.chunk-card__state {
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #0b6fd3;
|
||||
background: rgb(11 111 211 / 8%);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.chunk-card__path {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
@@ -240,15 +337,17 @@ watch(
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chunk-card__content {
|
||||
margin: 16px 0 0;
|
||||
margin: 14px 0 0;
|
||||
font-family: inherit;
|
||||
line-height: 1.7;
|
||||
font-size: 13px;
|
||||
line-height: 1.75;
|
||||
color: var(--el-text-color-regular);
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.chunk-card__warnings {
|
||||
@@ -259,28 +358,49 @@ watch(
|
||||
}
|
||||
|
||||
.qa-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
margin-top: 16px;
|
||||
background: var(--el-fill-color-light);
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
margin-top: 14px;
|
||||
background: rgb(15 23 42 / 3%);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.qa-block__item {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
line-height: 1.6;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.qa-block__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: #0b6fd3;
|
||||
background: rgb(11 111 211 / 8%);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.qa-block__text {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--el-text-color-regular);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.preview-shell__header,
|
||||
.chunk-card__header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chunk-card__meta {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user