436 lines
10 KiB
Vue
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>
|