feat: 完善知识库批量导入与公共 API

- 新增批量异步导入、状态查询、失败重试与中断恢复链路

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

- 补充数据库迁移、管理端交互、接口说明与相关测试
This commit is contained in:
2026-08-03 11:13:48 +08:00
parent 6df3dd9981
commit 51dbfd41b6
99 changed files with 16274 additions and 480 deletions

View File

@@ -32,6 +32,7 @@ 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 { resolveDocumentTaskErrorText } from '#/views/ai/documentCollection/document-import-error';
import { buildKnowledgePath } from '#/views/ai/documentCollection/share-path';
interface DocumentStatusPayload {
@@ -40,6 +41,7 @@ interface DocumentStatusPayload {
failedChunks?: number;
knowledgeId?: number | string;
lastTaskError?: string;
lastTaskErrorCode?: string;
parseCurrentStage?: string;
parseStatusMessage?: string;
processStatus?: string;
@@ -86,6 +88,7 @@ const STREAM_RECONNECT_DELAY = 1500;
const STREAM_RELOAD_DELAY = 250;
const pageDataRef = ref();
const retryingDocumentIds = ref<Set<string>>(new Set());
const taskStatusStreamClient = new SseClient();
let reconnectTimer: null | ReturnType<typeof setTimeout> = null;
let reloadTimer: null | ReturnType<typeof setTimeout> = null;
@@ -102,7 +105,7 @@ defineExpose({
},
});
const processingStatuses = new Set(['INDEXING', 'PARSING']);
const processingStatuses = new Set(['INDEXING', 'PARSING', 'SPLITTING']);
const isProcessingStatus = (status?: string) =>
processingStatuses.has(status || '');
@@ -113,7 +116,8 @@ const resolvedPermissions = computed(() => ({
canDownloadContent: props.permissions?.canDownloadContent ?? true,
}));
const hasPermission = (key: PermissionKey) => Boolean(resolvedPermissions.value[key]);
const hasPermission = (key: PermissionKey) =>
Boolean(resolvedPermissions.value[key]);
const statusMetaMap: Record<
string,
@@ -142,6 +146,14 @@ const statusMetaMap: Record<
icon: Loading,
toneClass: 'status-pill--warning',
},
SPLIT_FAILED: {
icon: CloseBold,
toneClass: 'status-pill--danger',
},
SPLITTING: {
icon: Loading,
toneClass: 'status-pill--warning',
},
READY_FOR_INDEX: {
icon: Opportunity,
toneClass: 'status-pill--primary',
@@ -204,9 +216,21 @@ const parseStageLabels: Record<string, string> = {
};
const getProcessingHint = (row: any) =>
row.parseStatusMessage ||
parseStageLabels[row.parseCurrentStage || ''] ||
'';
row.parseStatusMessage || parseStageLabels[row.parseCurrentStage || ''] || '';
const getErrorText = (row: any) => {
const taskError = resolveDocumentTaskErrorText(row, $t);
if (taskError) {
return taskError;
}
if (
row.processStatus === 'SPLIT_FAILED' ||
row.processStatus === 'INDEX_FAILED'
) {
return $t('documentCollection.importDoc.splitOrIndexFailed');
}
return '';
};
const clearReconnectTimer = () => {
if (!reconnectTimer) {
@@ -242,6 +266,7 @@ const patchDocumentRow = (payload: DocumentStatusPayload) => {
completedChunks: payload.completedChunks,
failedChunks: payload.failedChunks,
lastTaskError: payload.lastTaskError,
lastTaskErrorCode: payload.lastTaskErrorCode,
parseCurrentStage: payload.parseCurrentStage,
parseStatusMessage: payload.parseStatusMessage,
processStatus: payload.processStatus,
@@ -363,17 +388,33 @@ const handleContinue = (row: any) => {
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 handleRetry = async (row: any) => {
const documentId = String(row.id);
if (retryingDocumentIds.value.has(documentId)) {
return;
}
retryingDocumentIds.value = new Set([
documentId,
...retryingDocumentIds.value,
]);
try {
await requestTaskAction(
'/api/v1/document/import/task/retry',
{
knowledgeId: props.knowledgeId,
documentId: row.id,
},
getStatusLabel('PARSING'),
);
} finally {
const next = new Set(retryingDocumentIds.value);
next.delete(documentId);
retryingDocumentIds.value = next;
}
};
const isRetrying = (row: any) => retryingDocumentIds.value.has(String(row.id));
const handleView = (row: any) => {
emits('viewDoc', row.id);
};
@@ -428,13 +469,18 @@ const primaryActionConfigs: Record<
label: () => $t('button.viewSegmentation'),
},
INDEX_FAILED: {
handler: handleContinue,
label: () => $t('button.continueProcess'),
handler: handleRetry,
label: () => $t('documentCollection.importDoc.retry'),
permission: 'canCreateContent',
},
PARSE_FAILED: {
handler: handleRetryParse,
label: () => $t('button.retryParse'),
handler: handleRetry,
label: () => $t('documentCollection.importDoc.retry'),
permission: 'canCreateContent',
},
SPLIT_FAILED: {
handler: handleRetry,
label: () => $t('documentCollection.importDoc.retry'),
permission: 'canCreateContent',
},
READY_FOR_INDEX: {
@@ -583,7 +629,8 @@ watch(
<div
v-if="
row.processStatus === 'INDEXING' ||
row.processStatus === 'PARSING'
row.processStatus === 'PARSING' ||
row.processStatus === 'SPLITTING'
"
class="status-progress"
>
@@ -595,19 +642,24 @@ watch(
{{ getProgressText(row) }}
</span>
<span
v-if="row.processStatus === 'PARSING' && getProcessingHint(row)"
v-if="
row.processStatus === 'PARSING' && getProcessingHint(row)
"
class="status-progress__hint"
>
{{ getProcessingHint(row) }}
</span>
</div>
<div
v-else-if="row.lastTaskError"
class="status-error"
:title="row.lastTaskError"
<ElTooltip
v-else-if="getErrorText(row)"
:content="getErrorText(row)"
placement="top"
:show-after="300"
>
{{ row.lastTaskError }}
</div>
<div class="status-error">
{{ getErrorText(row) }}
</div>
</ElTooltip>
</div>
</template>
</ElTableColumn>
@@ -624,6 +676,8 @@ watch(
v-if="getPrimaryActionLabel(row)"
link
type="primary"
:disabled="isRetrying(row)"
:loading="isRetrying(row)"
@click="handlePrimaryAction(row)"
>
{{ getPrimaryActionLabel(row) }}