Files
EasyFlow/easyflow-ui-admin/app/src/views/ai/documentCollection/DocumentTable.vue
陈子默 bae7b18977 fix: 完善自动导入异常中断与恢复
- 自动导入基础设施异常触发批次熔断,保留完整日志并输出安全错误信息

- 增加恢复令牌与租约围栏、无文档失败项重建及消息退避机制

- 前端展示中断状态并在状态请求失败后自动恢复轮询

- 补充批次中断迁移、配置与并发异常路径测试
2026-08-10 11:26:00 +08:00

894 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import type { Component } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { $t } from '@easyflow/locales';
import { downloadFileFromBlob } from '@easyflow/utils';
import {
CloseBold,
Delete,
Download,
Files,
Loading,
Opportunity,
Select,
UploadFilled,
} from '@element-plus/icons-vue';
import {
ElButton,
ElIcon,
ElImage,
ElMessage,
ElMessageBox,
ElProgress,
ElTable,
ElTableColumn,
ElTooltip,
} from 'element-plus';
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 {
completedChunks?: number;
documentId?: number | string;
failedChunks?: number;
knowledgeId?: number | string;
lastTaskError?: string;
lastTaskErrorCode?: string;
parseCurrentStage?: string;
parseStatusMessage?: 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,
},
requestClient: {
type: Object as any,
default: () => api,
},
endpointPrefix: {
type: String,
default: '',
},
permissions: {
type: Object as () => DocumentTablePermissions,
default: () => ({
canCreateContent: true,
canDeleteContent: true,
canDownloadContent: true,
}),
},
});
const emits = defineEmits(['viewDoc', 'continueProcess']);
const STREAM_RECONNECT_DELAY = 1500;
const STREAM_RELOAD_DELAY = 3000;
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;
let disposed = false;
defineExpose({
reload() {
pageDataRef.value?.reload?.();
},
search(searchText: string) {
pageDataRef.value?.setQuery?.({
title: searchText,
});
},
});
const processingStatuses = new Set(['INDEXING', 'PARSING', 'SPLITTING']);
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',
},
SPLIT_FAILED: {
icon: CloseBold,
toneClass: 'status-pill--danger',
},
SPLITTING: {
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 defaultStatusMeta: {
icon: Component;
toneClass: string;
} = statusMetaMap.UPLOADED!;
const getStatusLabel = (status?: string) =>
$t(`documentCollection.taskStatus.${status || 'UPLOADED'}`);
const getStatusMeta = (
status?: string,
): {
icon: Component;
toneClass: string;
} => statusMetaMap[status || 'UPLOADED'] ?? defaultStatusMeta;
const getStatusToneClass = (status?: string) => getStatusMeta(status).toneClass;
const getStatusIcon = (status?: string) => getStatusMeta(status).icon;
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 (row.processStatus === 'PARSING') {
return `${Number(row.progressPercent || 0)}%`;
}
if (total <= 0) {
return `${Number(row.progressPercent || 0)}%`;
}
return `${Number(row.progressPercent || 0)}% · ${completed}/${total}`;
};
const parseStageLabels: Record<string, string> = {
assembling: '汇总中',
extracting: '提取中',
ocr: 'OCR 中',
preparing: '准备中',
queued: '排队中',
};
const getProcessingHint = (row: any) =>
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) {
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?.({ silent: true });
}, 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,
lastTaskErrorCode: payload.lastTaskErrorCode,
parseCurrentStage: payload.parseCurrentStage,
parseStatusMessage: payload.parseStatusMessage,
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 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);
};
const handleDownload = async (row: any) => {
const blob = await props.requestClient.download(
buildKnowledgePath(
props.endpointPrefix,
`/api/v1/document/download?documentId=${row.id}`,
),
);
downloadFileFromBlob({
fileName: row.title || 'document',
source: blob,
});
};
const handleDelete = (row: any) => {
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(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: handleRetry,
label: () => $t('documentCollection.importDoc.retry'),
permission: 'canCreateContent',
},
PARSE_FAILED: {
handler: handleRetry,
label: () => $t('documentCollection.importDoc.retry'),
permission: 'canCreateContent',
},
SPLIT_FAILED: {
handler: handleRetry,
label: () => $t('documentCollection.importDoc.retry'),
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;
}
clearReloadTimer();
openTaskStatusStream();
},
);
</script>
<template>
<PageData
:page-url="
buildKnowledgePath(props.endpointPrefix, '/api/v1/document/documentList')
"
ref="pageDataRef"
:page-size="10"
:request-client="props.requestClient"
:extra-query-params="{
id: props.knowledgeId,
sort: 'desc',
sortKey: 'created',
}"
>
<template #default="{ pageList }">
<ElTable :data="pageList" style="width: 100%" size="large">
<ElTableColumn
prop="fileName"
:label="$t('documentCollection.fileName')"
min-width="220"
align="left"
header-align="left"
>
<template #default="{ row }">
<span class="file-name-container">
<ElImage :src="documentIcon" class="mr-1" />
<span class="title">
{{ row.title }}
</span>
</span>
</template>
</ElTableColumn>
<ElTableColumn
:label="$t('documentCollection.chunkCount')"
width="96"
align="left"
header-align="left"
>
<template #default="{ row }">
{{ getChunkCount(row) }}
</template>
</ElTableColumn>
<ElTableColumn
:label="$t('documentCollection.createdModifyTime')"
width="176"
align="left"
header-align="left"
>
<template #default="{ row }">
<div class="time-container">
<span>{{ row.created }}</span>
<span>{{ row.modified }}</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn
:label="$t('documentCollection.processStatus')"
min-width="156"
align="left"
header-align="left"
>
<template #default="{ row }">
<div class="status-cell">
<div
class="status-pill"
:class="getStatusToneClass(row.processStatus)"
>
<span class="status-pill__icon-shell">
<ElIcon
class="status-pill__icon"
:class="
isProcessingStatus(row.processStatus)
? 'status-pill__icon--spinning'
: ''
"
>
<component :is="getStatusIcon(row.processStatus)" />
</ElIcon>
</span>
<span class="status-pill__label">
{{ getStatusLabel(row.processStatus) }}
</span>
</div>
<div
v-if="
row.processStatus === 'INDEXING' ||
row.processStatus === 'PARSING' ||
row.processStatus === 'SPLITTING'
"
class="status-progress"
>
<ElProgress
:percentage="Number(row.progressPercent || 0)"
:stroke-width="8"
/>
<span class="status-progress__text">
{{ getProgressText(row) }}
</span>
<span
v-if="
row.processStatus === 'PARSING' && getProcessingHint(row)
"
class="status-progress__hint"
>
{{ getProcessingHint(row) }}
</span>
</div>
<ElTooltip
v-else-if="getErrorText(row)"
:content="getErrorText(row)"
placement="top"
:show-after="300"
>
<div class="status-error">
{{ getErrorText(row) }}
</div>
</ElTooltip>
</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"
:disabled="isRetrying(row)"
:loading="isRetrying(row)"
@click="handlePrimaryAction(row)"
>
{{ getPrimaryActionLabel(row) }}
</ElButton>
<ElTooltip
v-if="hasPermission('canDownloadContent')"
:content="$t('button.download')"
placement="top"
>
<ElButton
link
:icon="Download"
:aria-label="$t('button.download')"
@click="handleDownload(row)"
/>
</ElTooltip>
<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>
</ElTable>
</template>
</PageData>
</template>
<style scoped>
.time-container {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-start;
}
.file-name-container {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.title {
font-size: 14px;
font-weight: 500;
line-height: 20px;
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;
}
.status-progress__hint {
font-size: 12px;
color: var(--el-text-color-secondary);
text-align: left;
}
.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>