feat: 重构知识库文档导入任务化流程

- 新增上传建单、异步解析、分块处理与异步向量化闭环

- 收口分享页权限、完成态检索过滤与 SSE 局部状态刷新
This commit is contained in:
2026-04-15 19:27:22 +08:00
parent a41b50959e
commit 2689adfa40
56 changed files with 6376 additions and 1060 deletions

View File

@@ -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>