feat: 在文件上传组件中添加上传进度显示和队列管理功能

This commit is contained in:
Wenjie Zhang 2026-03-25 07:28:30 +08:00
parent b1746c9270
commit 638da2afb8
2 changed files with 451 additions and 44 deletions

View File

@ -12,6 +12,7 @@
- 添加自定义向量模型和 rerank 模型的配置,在网页上面
- 调研轻便的文件展示与编辑器
- 移除 TODO 的模块与设计,移除这个中间件。
- Yuxi-cli 相关的功能,放在后续版本中实现(不是类似于编程助手,而是工具)
### Bugs
- 部分异常状态下,智能体的模型名称出现重叠[#279](https://github.com/xerrors/Yuxi-Know/issues/279)

View File

@ -12,11 +12,7 @@
type="primary"
@click="chunkData"
:loading="chunkLoading"
:disabled="
uploadMode === 'url'
? !urlList.some((i) => i.status === 'success')
: fileList.length === 0
"
:disabled="!canSubmit"
>
添加到知识库
</a-button>
@ -141,6 +137,7 @@
:multiple="true"
:directory="isFolderUpload"
:disabled="chunkLoading"
:show-upload-list="!showAggregateProgress"
:accept="acceptedFileTypes"
:before-upload="beforeUpload"
:customRequest="customRequest"
@ -153,6 +150,53 @@
<p class="ant-upload-hint">支持类型: {{ uploadHint }}</p>
<div class="zip-tip" v-if="hasZipFiles">📦 ZIP包将自动解压提取 Markdown 与图片</div>
</a-upload-dragger>
<div v-if="showAggregateProgress" class="upload-progress-card">
<div class="progress-header">
<div class="progress-header-left">
<div class="progress-title">上传进度</div>
<div class="progress-stats inline-in-header">
<div class="stat-pill">总计 {{ totalUploadCount }}</div>
<div class="stat-pill uploading" v-if="uploadingUploadCount > 0">
上传中 {{ uploadingUploadCount }}
</div>
<div class="stat-pill queued" v-if="queuedUploadCount > 0">排队 {{ queuedUploadCount }}</div>
<div class="stat-pill error" v-if="failedUploadCount > 0">失败 {{ failedUploadCount }}</div>
</div>
</div>
<div class="progress-header-right">
<div class="progress-percent">{{ overallUploadProgress }}%</div>
<a-button
type="text"
size="small"
class="toggle-progress-btn"
@click="progressExpanded = !progressExpanded"
>
<span>{{ progressExpanded ? '收起' : '展开' }}</span>
<ChevronUp v-if="progressExpanded" :size="14" />
<ChevronDown v-else :size="14" />
</a-button>
</div>
</div>
<div v-if="progressExpanded" class="progress-details">
<div class="details-list" v-if="failedDetailItems.length > 0">
<div v-for="item in failedDetailItems" :key="item.uid" class="detail-row">
<span class="detail-name" :title="item.name">{{ item.name }}</span>
<span class="detail-error" :title="item.errorText">{{ item.errorText }}</span>
</div>
</div>
<div class="progress-tip" v-else>当前无失败文件</div>
<div class="progress-tip" v-if="hasPendingUploads">
文件夹上传采用队列模式最多同时上传 {{ MAX_UPLOAD_CONCURRENCY }} 个文件
</div>
<div class="progress-tip" v-else>
上传队列已完成可点击添加到知识库继续下一步
</div>
</div>
</div>
</div>
<!-- URL 输入区域 -->
@ -263,7 +307,9 @@ import {
Download,
Trash2,
Link,
X
X,
ChevronDown,
ChevronUp
} from 'lucide-vue-next'
import { h } from 'vue'
import ChunkParamsConfig from '@/components/ChunkParamsConfig.vue'
@ -411,6 +457,69 @@ const chunkLoading = computed(() => store.state.chunkLoading)
//
const uploadMode = ref('file')
const previousOcrSelection = ref('disable')
const MAX_UPLOAD_CONCURRENCY = 10
//
const fileList = ref([])
const uploadQueue = ref([])
const activeUploadCount = ref(0)
const uploadTaskStatus = ref({})
const uploadTaskProgress = ref({})
const progressExpanded = ref(false)
const totalUploadCount = computed(() => fileList.value.length)
const queuedUploadCount = computed(
() => Object.values(uploadTaskStatus.value).filter((status) => status === 'queued').length
)
const uploadingUploadCount = computed(
() => Object.values(uploadTaskStatus.value).filter((status) => status === 'uploading').length
)
const successUploadCount = computed(
() => Object.values(uploadTaskStatus.value).filter((status) => status === 'done').length
)
const failedUploadCount = computed(
() => Object.values(uploadTaskStatus.value).filter((status) => status === 'error').length
)
const hasPendingUploads = computed(() => queuedUploadCount.value + uploadingUploadCount.value > 0)
const overallUploadProgress = computed(() => {
const total = totalUploadCount.value
if (!total) {
return 0
}
const validUidSet = new Set(fileList.value.map((file) => file.uid).filter(Boolean))
let sum = 0
for (const uid of validUidSet) {
sum += uploadTaskProgress.value[uid] || 0
}
return Math.round(sum / total)
})
const showAggregateProgress = computed(() => totalUploadCount.value >= MAX_UPLOAD_CONCURRENCY)
const failedDetailItems = computed(() => {
return fileList.value
.map((file) => {
const uid = file.uid
const rawStatus = uploadTaskStatus.value[uid] || file.status || 'unknown'
const detail = file?.response?.detail || file?.error?.message || ''
return {
uid,
name: file.name || '未命名文件',
status: rawStatus,
errorText: detail || '上传失败'
}
})
.filter((item) => item.status === 'error')
})
const canSubmit = computed(() => {
if (uploadMode.value === 'url') {
return urlList.value.some((item) => item.status === 'success')
}
return successUploadCount.value > 0 && !hasPendingUploads.value
})
const uploadModeOptions = computed(() => [
{
@ -443,10 +552,34 @@ watch(uploadMode, (val) => {
sameNameFiles.value = []
urlList.value = []
newUrl.value = ''
for (const task of uploadQueue.value) {
task.canceled = true
}
uploadQueue.value = []
uploadTaskStatus.value = {}
uploadTaskProgress.value = {}
progressExpanded.value = false
})
//
const fileList = ref([])
watch(fileList, (newFileList) => {
const validUidSet = new Set(newFileList.map((file) => file.uid).filter(Boolean))
const nextStatus = {}
const nextProgress = {}
for (const [uid, status] of Object.entries(uploadTaskStatus.value)) {
if (validUidSet.has(uid)) {
nextStatus[uid] = status
}
}
for (const [uid, progress] of Object.entries(uploadTaskProgress.value)) {
if (validUidSet.has(uid)) {
nextProgress[uid] = progress
}
}
uploadTaskStatus.value = nextStatus
uploadTaskProgress.value = nextProgress
})
// URL
// Item structure: { url: string, status: 'fetching'|'success'|'error', data: object|null, error: string }
@ -882,56 +1015,161 @@ const deleteSameNameFile = (file) => {
}
const customRequest = async (options) => {
const { file, onProgress, onSuccess, onError } = options
const formData = new FormData()
// 使
const filename =
isFolderUpload.value && file.webkitRelativePath ? file.webkitRelativePath : file.name
formData.append('file', file, filename)
const dbId = databaseId.value
if (!dbId) {
onError(new Error('Database ID is missing'))
return
const fileUid = options.file?.uid
if (fileUid) {
uploadTaskStatus.value[fileUid] = 'queued'
uploadTaskProgress.value[fileUid] = 0
}
const xhr = new XMLHttpRequest()
xhr.open('POST', `/api/knowledge/files/upload?db_id=${dbId}`)
const headers = getAuthHeaders()
for (const [key, value] of Object.entries(headers)) {
xhr.setRequestHeader(key, value)
const task = {
options,
xhr: null,
canceled: false
}
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
onProgress({ percent: (e.loaded / e.total) * 100 })
uploadQueue.value.push(task)
processUploadQueue()
return {
abort: () => {
task.canceled = true
if (task.xhr) {
task.xhr.abort()
}
const queueIndex = uploadQueue.value.indexOf(task)
if (queueIndex !== -1) {
uploadQueue.value.splice(queueIndex, 1)
}
if (fileUid) {
uploadTaskStatus.value[fileUid] = 'error'
}
}
}
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const response = JSON.parse(xhr.responseText)
onSuccess(response, xhr)
} catch (e) {
onError(e)
const processUploadQueue = () => {
while (activeUploadCount.value < MAX_UPLOAD_CONCURRENCY && uploadQueue.value.length > 0) {
const task = uploadQueue.value.shift()
if (!task || task.canceled) {
continue
}
activeUploadCount.value += 1
runUploadTask(task)
.catch(() => {
// runUploadTask
})
.finally(() => {
activeUploadCount.value -= 1
processUploadQueue()
})
}
}
const runUploadTask = (task) => {
const { file, onProgress, onSuccess, onError } = task.options
const fileUid = file?.uid
if (fileUid) {
uploadTaskStatus.value[fileUid] = 'uploading'
}
return new Promise((resolve, reject) => {
const formData = new FormData()
const filename =
isFolderUpload.value && file.webkitRelativePath ? file.webkitRelativePath : file.name
formData.append('file', file, filename)
const dbId = databaseId.value
if (!dbId) {
const error = new Error('Database ID is missing')
if (fileUid) {
uploadTaskStatus.value[fileUid] = 'error'
}
onError(error)
reject(error)
return
}
const xhr = new XMLHttpRequest()
task.xhr = xhr
xhr.open('POST', `/api/knowledge/files/upload?db_id=${dbId}`)
const headers = getAuthHeaders()
for (const [key, value] of Object.entries(headers)) {
xhr.setRequestHeader(key, value)
}
xhr.upload.onprogress = (event) => {
if (!event.lengthComputable) {
return
}
const percent = Math.min(100, (event.loaded / event.total) * 100)
if (fileUid) {
uploadTaskProgress.value[fileUid] = percent
}
onProgress({ percent })
}
xhr.onload = () => {
if (task.canceled) {
resolve()
return
}
if (xhr.status >= 200 && xhr.status < 300) {
try {
const response = JSON.parse(xhr.responseText)
if (fileUid) {
uploadTaskStatus.value[fileUid] = 'done'
uploadTaskProgress.value[fileUid] = 100
}
onSuccess(response, xhr)
resolve()
} catch (error) {
if (fileUid) {
uploadTaskStatus.value[fileUid] = 'error'
}
onError(error)
reject(error)
}
return
}
let errorResp = {}
try {
errorResp = JSON.parse(xhr.responseText || '{}')
} catch {
errorResp = {}
}
} else {
const errorResp = JSON.parse(xhr.responseText)
// file.response handleFileUpload
file.response = errorResp
const error = new Error(errorResp.detail || 'Upload failed')
if (fileUid) {
uploadTaskStatus.value[fileUid] = 'error'
}
onError(error, file)
reject(error)
}
}
xhr.onerror = (e) => {
onError(e)
}
xhr.onerror = (errorEvent) => {
if (fileUid) {
uploadTaskStatus.value[fileUid] = 'error'
}
onError(errorEvent)
reject(errorEvent)
}
xhr.send(formData)
xhr.onabort = () => {
if (fileUid) {
uploadTaskStatus.value[fileUid] = 'error'
}
const abortError = new Error('Upload aborted')
onError(abortError)
reject(abortError)
}
xhr.send(formData)
})
}
const handleFileUpload = (info) => {
@ -1425,6 +1663,152 @@ const chunkData = async () => {
border-radius: 4px;
}
.upload-progress-card {
margin-top: 8px;
border: 1px solid var(--gray-200);
border-radius: 8px;
background: var(--gray-50);
padding: 8px;
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.progress-header-left {
display: flex;
flex-direction: row;
gap: 6px;
align-items: center;
min-width: 0;
}
.progress-header-right {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.progress-title {
font-size: 12px;
font-weight: 600;
color: var(--gray-700);
white-space: nowrap;
}
.progress-percent {
font-size: 14px;
font-weight: 700;
color: var(--main-600);
}
.progress-stats {
display: flex;
flex-wrap: wrap;
gap: 8px;
&.inline-in-header {
gap: 6px;
}
}
.stat-pill {
border-radius: 999px;
padding: 1px 8px;
font-size: 11px;
line-height: 1.4;
border: 1px solid var(--gray-300);
background: var(--gray-100);
color: var(--gray-600);
&.uploading {
background: var(--main-50);
border-color: var(--main-200);
color: var(--main-600);
}
&.queued {
background: var(--gray-100);
border-color: var(--gray-300);
color: var(--gray-600);
}
&.success {
background: var(--color-success-50);
border-color: var(--color-success-200);
color: var(--color-success-600);
}
&.error {
background: var(--color-error-50);
border-color: var(--color-error-200);
color: var(--color-error-600);
}
}
.progress-tip {
margin-top: 6px;
font-size: 11px;
color: var(--gray-500);
}
.progress-details {
border-top: 1px dashed var(--gray-200);
padding-top: 6px;
}
.details-list {
max-height: 160px;
overflow-y: auto;
border: 1px solid var(--gray-200);
border-radius: 6px;
background: var(--gray-0);
}
.detail-row {
padding: 6px 8px;
border-bottom: 1px solid var(--gray-100);
&:last-child {
border-bottom: none;
}
}
.detail-name {
font-size: 11px;
color: var(--gray-700);
font-weight: 500;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.detail-error {
margin-top: 2px;
font-size: 11px;
color: var(--color-error-600);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.toggle-progress-btn {
color: var(--gray-500);
display: inline-flex;
align-items: center;
gap: 4px;
padding-inline: 4px;
&:hover {
color: var(--main-600);
background: var(--gray-100);
}
}
/* URL Area */
.url-area {
flex: 1;
@ -1654,4 +2038,26 @@ const chunkData = async () => {
.setting-label .ant-checkbox {
margin-right: 8px;
}
@media (max-width: 768px) {
.top-action-bar {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.auto-index-toggle {
padding-right: 0;
}
.progress-header {
flex-direction: column;
gap: 8px;
}
.progress-header-right {
width: 100%;
justify-content: space-between;
}
}
</style>