feat: 完善知识库批量导入与公共 API
- 新增批量异步导入、状态查询、失败重试与中断恢复链路 - 拆分知识库读取、导入、维护权限并完善 Public API 契约 - 补充数据库迁移、管理端交互、接口说明与相关测试
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { $t } from '@easyflow/locales';
|
||||
|
||||
import { ElButton, ElProgress } from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
|
||||
interface BatchStatus {
|
||||
batchId: string;
|
||||
completedCount: number;
|
||||
failedCount: number;
|
||||
importMode: 'AUTO' | 'MANUAL';
|
||||
pendingCount: number;
|
||||
processingCount: number;
|
||||
progressPercent: number;
|
||||
retryableFailedCount?: 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);
|
||||
let pollTimer: null | ReturnType<typeof setTimeout> = null;
|
||||
let disposed = false;
|
||||
let refreshGeneration = 0;
|
||||
|
||||
const canContinue = computed(
|
||||
() =>
|
||||
props.manageable &&
|
||||
(batch.value?.status === 'INTERRUPTED' ||
|
||||
batch.value?.status === 'PARTIAL_SUCCEEDED') &&
|
||||
(Number(batch.value?.retryableFailedCount || 0) > 0 ||
|
||||
Number(batch.value?.failedCount || 0) > 0),
|
||||
);
|
||||
|
||||
const processedCount = computed(
|
||||
() =>
|
||||
Number(batch.value?.completedCount || 0) +
|
||||
Number(batch.value?.processingCount || 0),
|
||||
);
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
async function refresh(hideCompletedOnRestore = false) {
|
||||
if (!props.knowledgeId) return;
|
||||
const currentGeneration = ++refreshGeneration;
|
||||
try {
|
||||
const response = await api.get('/api/v1/document/import/batch/current', {
|
||||
params: { knowledgeId: props.knowledgeId },
|
||||
});
|
||||
if (disposed || currentGeneration !== refreshGeneration) {
|
||||
return;
|
||||
}
|
||||
const restoredBatch =
|
||||
response.errorCode === 0 ? response.data || undefined : undefined;
|
||||
batch.value =
|
||||
hideCompletedOnRestore && restoredBatch?.status === 'COMPLETED'
|
||||
? undefined
|
||||
: restoredBatch;
|
||||
} finally {
|
||||
if (!disposed && currentGeneration === refreshGeneration) {
|
||||
schedulePoll();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePoll() {
|
||||
if (pollTimer) clearTimeout(pollTimer);
|
||||
pollTimer = null;
|
||||
if (
|
||||
disposed ||
|
||||
!batch.value ||
|
||||
batch.value.status === 'COMPLETED' ||
|
||||
batch.value.status === 'PARTIAL_SUCCEEDED' ||
|
||||
batch.value.status === 'INTERRUPTED' ||
|
||||
batch.value.status === 'CANCELLED'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
pollTimer = setTimeout(refresh, 3000);
|
||||
}
|
||||
|
||||
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(true);
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
refresh(knowledgeChanged);
|
||||
},
|
||||
);
|
||||
</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>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.batch-status {
|
||||
display: flex;
|
||||
flex: 1 1 360px;
|
||||
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;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.batch-status {
|
||||
flex-basis: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user