Files
EasyFlow/easyflow-ui-admin/app/src/views/ai/documentCollection/DocumentImportBatchStatus.vue
陈子默 1870ac4028 fix: 修正自动导入完成提示
- 根据关联文档真实状态识别已完成的历史失败批次

- 完成后自动移除提示并低频复查失败或中断批次
2026-08-26 18:58:54 +08:00

436 lines
10 KiB
Vue

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { $t } from '@easyflow/locales';
import { ElAlert, ElButton, ElProgress } from 'element-plus';
import { api } from '#/api/request';
interface BatchStatus {
actualCompleted?: boolean;
batchId: string;
cancelledCount?: number;
completedCount: number;
failedCount: number;
importMode: 'AUTO' | 'MANUAL';
interruptCode?: string;
interruptedAt?: string;
interruptMessage?: string;
pendingCount: number;
processingCount: number;
progressPercent: number;
skippedCount: number;
status:
| 'CANCELLED'
| 'COMPLETED'
| 'INTERRUPTED'
| 'PARTIAL_SUCCEEDED'
| 'RUNNING';
totalCount: number;
}
const props = defineProps({
knowledgeId: {
type: String,
required: true,
},
refreshKey: {
type: Number,
default: 0,
},
manageable: {
type: Boolean,
default: false,
},
});
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 TERMINAL_RECHECK_DELAY_MS = 15_000;
const canContinue = computed(
() =>
props.manageable &&
(batch.value?.status === 'INTERRUPTED' ||
batch.value?.status === 'PARTIAL_SUCCEEDED') &&
Number(batch.value?.failedCount || 0) > 0,
);
const processedCount = computed(() => {
const processed =
Number(batch.value?.completedCount || 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(
() =>
Number(batch.value?.totalCount || 0) > 0 &&
Number(batch.value?.failedCount || 0) ===
Number(batch.value?.totalCount || 0),
);
const statusLabel = computed(() => {
const status = batch.value?.status;
if (status === 'COMPLETED') {
return $t('documentCollection.importDoc.batchCompleted');
}
if (status === 'INTERRUPTED') {
return $t('documentCollection.importDoc.batchInterrupted');
}
if (status === 'PARTIAL_SUCCEEDED') {
return $t(
allFailed.value
? 'documentCollection.importDoc.batchFailed'
: 'documentCollection.importDoc.batchPartial',
);
}
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() {
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 },
});
if (disposed || currentGeneration !== refreshGeneration) {
return;
}
if (response.errorCode !== 0) {
loadError.value =
response.message ||
$t('documentCollection.importDoc.batchStatusLoadFailed');
return;
}
loadError.value = '';
pollDelayMs = 3000;
const restoredBatch = response.data || undefined;
const completed =
restoredBatch?.status === 'COMPLETED' || restoredBatch?.actualCompleted;
batch.value = completed ? undefined : restoredBatch;
} catch {
if (!disposed && currentGeneration === refreshGeneration) {
loadError.value = $t(
'documentCollection.importDoc.batchStatusLoadFailed',
);
}
} finally {
if (!disposed && currentGeneration === refreshGeneration) {
refreshing.value = false;
schedulePoll();
}
}
}
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 (
!batch.value ||
batch.value.status === 'COMPLETED' ||
batch.value.status === 'CANCELLED'
) {
return;
}
const delay =
batch.value.status === 'PARTIAL_SUCCEEDED' ||
batch.value.status === 'INTERRUPTED'
? TERMINAL_RECHECK_DELAY_MS
: 3000;
pollTimer = setTimeout(refresh, delay);
}
async function continueBatch() {
if (!batch.value || continuing.value) return;
continuing.value = true;
try {
const response = await api.post('/api/v1/document/import/batch/continue', {
batchId: batch.value.batchId,
knowledgeId: props.knowledgeId,
});
if (response.errorCode === 0) {
batch.value = response.data;
emit('continued');
schedulePoll();
}
} finally {
continuing.value = false;
}
}
onMounted(() => {
disposed = false;
refresh();
});
onBeforeUnmount(() => {
disposed = true;
refreshGeneration += 1;
if (pollTimer) clearTimeout(pollTimer);
});
watch(
() => [props.knowledgeId, props.refreshKey] as const,
([knowledgeId], [previousKnowledgeId]) => {
const knowledgeChanged = knowledgeId !== previousKnowledgeId;
if (knowledgeChanged) {
batch.value = undefined;
loadError.value = '';
pollDelayMs = 3000;
}
refresh();
},
);
</script>
<template>
<section
v-if="batch"
class="batch-status"
:aria-label="$t('documentCollection.importDoc.batchStatus')"
>
<div class="batch-status__summary">
<div class="batch-status__headline">
<span class="batch-status__title">
{{ $t('documentCollection.importDoc.autoImport') }}
</span>
<span class="batch-status__count">
{{ processedCount }} / {{ batch.totalCount }}
</span>
<span class="batch-status__state">{{ statusLabel }}</span>
</div>
<ElProgress
class="batch-status__progress"
:percentage="Number(batch.progressPercent || 0)"
:show-text="false"
:stroke-width="7"
:status="
batch.status === 'COMPLETED'
? 'success'
: allFailed
? 'exception'
: undefined
"
/>
<div class="batch-status__metrics">
<span>
{{ $t('documentCollection.importDoc.completedCount') }}
{{ batch.completedCount }}
</span>
<span>
{{ $t('documentCollection.importDoc.processingCount') }}
{{ batch.processingCount }}
</span>
<span
:class="{ 'batch-status__metric--danger': batch.failedCount > 0 }"
>
{{ $t('documentCollection.importDoc.failedCount') }}
{{ batch.failedCount }}
</span>
<span>
{{ $t('documentCollection.importDoc.pendingCount') }}
{{ batch.pendingCount }}
</span>
<span v-if="batch.skippedCount > 0">
{{ $t('documentCollection.importDoc.skippedCount') }}
{{ batch.skippedCount }}
</span>
</div>
</div>
<ElButton
v-if="canContinue"
class="batch-status__continue"
type="primary"
link
:loading="continuing"
@click="continueBatch"
>
{{ $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>
<style scoped>
.batch-status {
display: flex;
flex: 1 1 360px;
flex-wrap: wrap;
gap: 12px;
align-items: center;
min-width: min(360px, 100%);
max-width: 620px;
padding: 8px 12px;
background: hsl(var(--surface-contrast-soft) / 74%);
border: 1px solid var(--el-border-color-lighter);
border-radius: 12px;
}
.batch-status__summary {
flex: 1;
min-width: 0;
}
.batch-status__headline,
.batch-status__metrics {
display: flex;
flex-wrap: wrap;
gap: 4px 10px;
align-items: center;
}
.batch-status__headline {
margin-bottom: 5px;
font-size: 12px;
}
.batch-status__title {
font-weight: 600;
color: var(--el-text-color-primary);
}
.batch-status__count {
font-variant-numeric: tabular-nums;
color: var(--el-color-primary);
}
.batch-status__state {
color: var(--el-text-color-secondary);
}
.batch-status__progress {
width: 100%;
}
.batch-status__metrics {
margin-top: 4px;
overflow: hidden;
font-size: 11px;
line-height: 16px;
color: var(--el-text-color-secondary);
white-space: nowrap;
}
.batch-status__metric--danger {
color: var(--el-color-danger);
}
.batch-status__continue {
flex-shrink: 0;
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%;
max-width: none;
}
}
</style>