fix: 完善自动导入异常中断与恢复

- 自动导入基础设施异常触发批次熔断,保留完整日志并输出安全错误信息

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

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

- 补充批次中断迁移、配置与并发异常路径测试
This commit is contained in:
2026-08-10 11:26:00 +08:00
parent 54d85ae460
commit bae7b18977
33 changed files with 3725 additions and 221 deletions

View File

@@ -3,15 +3,19 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { $t } from '@easyflow/locales';
import { ElButton, ElProgress } from 'element-plus';
import { ElAlert, ElButton, ElProgress } from 'element-plus';
import { api } from '#/api/request';
interface BatchStatus {
batchId: string;
cancelledCount?: number;
completedCount: number;
failedCount: number;
importMode: 'AUTO' | 'MANUAL';
interruptCode?: string;
interruptedAt?: string;
interruptMessage?: string;
pendingCount: number;
processingCount: number;
progressPercent: number;
@@ -43,9 +47,12 @@ const props = defineProps({
const emit = defineEmits(['continued']);
const batch = ref<BatchStatus>();
const continuing = ref(false);
const loadError = ref('');
const refreshing = ref(false);
let pollTimer: null | ReturnType<typeof setTimeout> = null;
let disposed = false;
let refreshGeneration = 0;
let pollDelayMs = 3000;
const canContinue = computed(
() =>
@@ -55,11 +62,15 @@ const canContinue = computed(
Number(batch.value?.failedCount || 0) > 0,
);
const processedCount = computed(
() =>
const processedCount = computed(() => {
const processed =
Number(batch.value?.completedCount || 0) +
Number(batch.value?.processingCount || 0),
);
Number(batch.value?.processingCount || 0) +
Number(batch.value?.failedCount || 0) +
Number(batch.value?.skippedCount || 0) +
Number(batch.value?.cancelledCount || 0);
return Math.min(Number(batch.value?.totalCount || 0), processed);
});
const allFailed = computed(
() =>
@@ -86,9 +97,35 @@ const statusLabel = computed(() => {
return $t('documentCollection.importDoc.batchRunning');
});
const interruptMetadata = computed(() => {
if (batch.value?.status !== 'INTERRUPTED') {
return '';
}
const details: string[] = [];
if (batch.value.interruptCode) {
details.push(
`${$t('documentCollection.importDoc.interruptCode')} ${
batch.value.interruptCode
}`,
);
}
if (batch.value.interruptedAt) {
const interruptedAt = new Date(batch.value.interruptedAt);
details.push(
`${$t('documentCollection.importDoc.interruptedAt')} ${
Number.isNaN(interruptedAt.getTime())
? batch.value.interruptedAt
: interruptedAt.toLocaleString()
}`,
);
}
return details.join(' · ');
});
async function refresh(hideCompletedOnRestore = false) {
if (!props.knowledgeId) return;
const currentGeneration = ++refreshGeneration;
refreshing.value = true;
try {
const response = await api.get('/api/v1/document/import/batch/current', {
params: { knowledgeId: props.knowledgeId },
@@ -96,14 +133,28 @@ async function refresh(hideCompletedOnRestore = false) {
if (disposed || currentGeneration !== refreshGeneration) {
return;
}
const restoredBatch =
response.errorCode === 0 ? response.data || undefined : undefined;
if (response.errorCode !== 0) {
loadError.value =
response.message ||
$t('documentCollection.importDoc.batchStatusLoadFailed');
return;
}
loadError.value = '';
pollDelayMs = 3000;
const restoredBatch = response.data || undefined;
batch.value =
hideCompletedOnRestore && restoredBatch?.status === 'COMPLETED'
? undefined
: restoredBatch;
} catch {
if (!disposed && currentGeneration === refreshGeneration) {
loadError.value = $t(
'documentCollection.importDoc.batchStatusLoadFailed',
);
}
} finally {
if (!disposed && currentGeneration === refreshGeneration) {
refreshing.value = false;
schedulePoll();
}
}
@@ -112,8 +163,16 @@ async function refresh(hideCompletedOnRestore = false) {
function schedulePoll() {
if (pollTimer) clearTimeout(pollTimer);
pollTimer = null;
if (disposed) {
return;
}
if (loadError.value) {
const retryDelay = pollDelayMs;
pollDelayMs = Math.min(pollDelayMs * 2, 30_000);
pollTimer = setTimeout(refresh, retryDelay);
return;
}
if (
disposed ||
!batch.value ||
batch.value.status === 'COMPLETED' ||
batch.value.status === 'PARTIAL_SUCCEEDED' ||
@@ -160,6 +219,8 @@ watch(
const knowledgeChanged = knowledgeId !== previousKnowledgeId;
if (knowledgeChanged) {
batch.value = undefined;
loadError.value = '';
pollDelayMs = 3000;
}
refresh(knowledgeChanged);
},
@@ -230,6 +291,46 @@ watch(
>
{{ $t('documentCollection.importDoc.continueBatch') }}
</ElButton>
<ElAlert
v-if="batch.status === 'INTERRUPTED'"
class="batch-status__alert"
type="error"
:closable="false"
show-icon
:title="
batch.interruptMessage ||
$t('documentCollection.importDoc.interruptFallback')
"
:description="interruptMetadata"
/>
<div v-if="loadError" class="batch-status__load-error">
<ElAlert
class="batch-status__alert"
type="error"
:closable="false"
show-icon
:title="loadError"
/>
<ElButton type="primary" link :loading="refreshing" @click="refresh()">
{{ $t('documentCollection.importDoc.retry') }}
</ElButton>
</div>
</section>
<section
v-else-if="loadError"
class="batch-status batch-status--error"
:aria-label="$t('documentCollection.importDoc.batchStatus')"
>
<ElAlert
class="batch-status__alert"
type="error"
:closable="false"
show-icon
:title="loadError"
/>
<ElButton type="primary" link :loading="refreshing" @click="refresh()">
{{ $t('documentCollection.importDoc.retry') }}
</ElButton>
</section>
</template>
@@ -237,6 +338,7 @@ watch(
.batch-status {
display: flex;
flex: 1 1 360px;
flex-wrap: wrap;
gap: 12px;
align-items: center;
min-width: min(360px, 100%);
@@ -301,6 +403,25 @@ watch(
min-width: 48px;
}
.batch-status__alert {
flex: 1 0 100%;
}
.batch-status__load-error {
display: flex;
flex: 1 0 100%;
gap: 8px;
align-items: center;
}
.batch-status__load-error .batch-status__alert {
flex-basis: auto;
}
.batch-status--error .batch-status__alert {
flex-basis: 320px;
}
@media (max-width: 900px) {
.batch-status {
flex-basis: 100%;