feat: 完善知识库批量导入与公共 API

- 新增批量异步导入、状态查询、失败重试与中断恢复链路

- 拆分知识库读取、导入、维护权限并完善 Public API 契约

- 补充数据库迁移、管理端交互、接口说明与相关测试
This commit is contained in:
2026-08-03 11:13:48 +08:00
parent 6df3dd9981
commit 51dbfd41b6
99 changed files with 16274 additions and 480 deletions

View File

@@ -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>