feat(avatar): show multi-file knowledge upload progress
This commit is contained in:
@@ -306,6 +306,8 @@ export interface KnowledgeDoc {
|
||||
embeddingModel?: string
|
||||
chunkCount?: number
|
||||
errorMessage?: string
|
||||
indexStage?: string
|
||||
indexProgress?: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@@ -332,12 +334,18 @@ export const getKnowledgeDocs = (avatarId: string) =>
|
||||
request.get<KnowledgeDoc[]>(`/avatar/${avatarId}/knowledge/docs`)
|
||||
|
||||
// 上传文档(支持 md/txt/pdf/doc/docx/xlsx)
|
||||
export const uploadKnowledgeDoc = (avatarId: string, file: File) => {
|
||||
export const uploadKnowledgeDoc = (
|
||||
avatarId: string,
|
||||
file: File,
|
||||
onUploadProgress?: (loaded: number, total: number) => void
|
||||
) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return request.post<KnowledgeDoc>(`/avatar/${avatarId}/knowledge/docs`, form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 120000
|
||||
// A slow mobile uplink must not be mistaken for a failed upload.
|
||||
timeout: 10 * 60 * 1000,
|
||||
onUploadProgress: (event) => onUploadProgress?.(event.loaded, event.total || file.size)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
<template v-else>
|
||||
<div class="tab-switcher" role="tablist" aria-label="知识库类型">
|
||||
<button class="tab-btn" :class="{ active: activeTab === 'docs' }" role="tab" :aria-selected="activeTab === 'docs'" @click="activeTab = 'docs'">文档知识库 <b>{{ docs.length }}</b></button>
|
||||
<button class="tab-btn" :class="{ active: activeTab === 'docs' }" role="tab" :aria-selected="activeTab === 'docs'" @click="activeTab = 'docs'">文档知识库 <b>{{ displayDocs.length }}</b></button>
|
||||
<button class="tab-btn" :class="{ active: activeTab === 'qa' }" role="tab" :aria-selected="activeTab === 'qa'" @click="activeTab = 'qa'">标准问答对 <b>{{ qaPairs.length }}</b></button>
|
||||
</div>
|
||||
|
||||
@@ -25,14 +25,14 @@
|
||||
<div class="upload-icon">📥</div>
|
||||
<p class="upload-title"><span class="upload-link">点击上传</span></p>
|
||||
<p class="upload-hint">支持 MD / TXT / PDF / DOC / DOCX / XLSX,上传后自动向量化</p>
|
||||
<input ref="fileInput" type="file" accept=".md,.txt,.pdf,.doc,.docx,.xlsx" class="hidden-input" @change="onFileChange" />
|
||||
<input ref="fileInput" type="file" multiple accept=".md,.txt,.pdf,.doc,.docx,.xlsx" class="hidden-input" @change="onFileChange" />
|
||||
</div>
|
||||
<p v-if="uploading" class="uploading-text">文件上传中…</p>
|
||||
<p v-if="uploading" class="uploading-text">{{ pendingUploads.length }} 个文件正在上传</p>
|
||||
<p v-if="uploadError" class="error-text">{{ uploadError }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="docs.length" class="mobile-card-list">
|
||||
<article v-for="doc in docs" :key="doc.id" class="knowledge-card">
|
||||
<div v-if="displayDocs.length" class="mobile-card-list">
|
||||
<article v-for="doc in displayDocs" :key="doc.id" class="knowledge-card">
|
||||
<div class="card-icon">{{ fileEmoji(doc.fileType) }}</div>
|
||||
<div class="card-content">
|
||||
<div class="card-title-row">
|
||||
@@ -41,10 +41,13 @@
|
||||
</div>
|
||||
<p class="card-meta">{{ doc.fileType.toUpperCase() }} · {{ formatSize(doc.fileSize) }} · {{ formatDate(doc.createdAt) }}</p>
|
||||
<p class="card-detail">{{ documentState(doc).detail }}</p>
|
||||
<div v-if="documentState(doc).progress !== undefined" class="progress-track" :aria-label="`${documentState(doc).label} ${documentState(doc).progress}%`">
|
||||
<span class="progress-fill" :style="{ width: `${documentState(doc).progress}%` }"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button v-if="documentState(doc).tone === 'failed'" class="card-retry" @click="retryDoc(doc.id)">重新索引</button>
|
||||
<button class="card-delete" @click="removeDoc(doc.id)">删除</button>
|
||||
<button v-if="!doc.localUploading" class="card-delete" @click="removeDoc(doc.id)">{{ doc.localOnly ? '移除' : '删除' }}</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
@@ -106,8 +109,9 @@ const avatarId = computed(() => pickScopedAvatarId(route.params.avatarId, store.
|
||||
const activeTab = ref<'docs' | 'qa'>('docs')
|
||||
|
||||
const docs = ref<any[]>([])
|
||||
const pendingUploads = ref<any[]>([])
|
||||
const qaPairs = ref<any[]>([])
|
||||
const uploading = ref(false)
|
||||
const uploading = computed(() => pendingUploads.value.some((doc) => doc.localUploading))
|
||||
const uploadError = ref('')
|
||||
const dragOver = ref(false)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
@@ -118,7 +122,15 @@ const searching = ref(false)
|
||||
const searched = ref(false)
|
||||
const searchResults = ref<any[]>([])
|
||||
|
||||
const displayDocs = computed(() => [...pendingUploads.value, ...docs.value])
|
||||
|
||||
const documentState = (doc: any) => {
|
||||
if (doc.localUploading) {
|
||||
return { tone: 'pending', label: '上传中', detail: `正在上传 ${doc.uploadProgress || 0}%`, progress: doc.uploadProgress || 0 }
|
||||
}
|
||||
if (doc.localOnly) {
|
||||
return { tone: 'failed', label: '上传失败', detail: doc.errorMessage || '文件未上传成功,请移除后重试' }
|
||||
}
|
||||
if (doc.filePresent === false) {
|
||||
return { tone: 'missing', label: '文件缺失', detail: '原文件不可用,请删除后重新上传' }
|
||||
}
|
||||
@@ -126,7 +138,12 @@ const documentState = (doc: any) => {
|
||||
return { tone: 'ready', label: '已入库', detail: `已切分 ${doc.chunkCount || 0} 段,可用于对话` }
|
||||
}
|
||||
if (['uploaded', 'parsing'].includes(String(doc.status || '').toLowerCase())) {
|
||||
return { tone: 'pending', label: '处理中', detail: '正在解析并建立知识索引' }
|
||||
const stage = String(doc.indexStage || 'queued').toLowerCase()
|
||||
const labels: Record<string, string> = {
|
||||
queued: '等待处理', extracting: '解析文档', chunking: '切分文本', embedding: '向量化中'
|
||||
}
|
||||
const progress = Math.max(0, Math.min(99, Number(doc.indexProgress || 0)))
|
||||
return { tone: 'pending', label: labels[stage] || '处理中', detail: `${labels[stage] || '正在建立知识索引'} ${progress}%`, progress }
|
||||
}
|
||||
return { tone: 'failed', label: '处理失败', detail: doc.errorMessage || '未能建立知识索引,请重新索引或重新上传' }
|
||||
}
|
||||
@@ -174,36 +191,62 @@ const loadQA = async () => {
|
||||
const triggerFile = () => fileInput.value?.click()
|
||||
|
||||
const onFileChange = (e: Event) => {
|
||||
const f = (e.target as HTMLInputElement).files?.[0]
|
||||
if (f) doUpload(f)
|
||||
const files = Array.from((e.target as HTMLInputElement).files || [])
|
||||
if (files.length) uploadFiles(files)
|
||||
;(e.target as HTMLInputElement).value = ''
|
||||
}
|
||||
|
||||
const onDrop = (e: DragEvent) => {
|
||||
dragOver.value = false
|
||||
const f = e.dataTransfer?.files?.[0]
|
||||
if (f) doUpload(f)
|
||||
const files = Array.from(e.dataTransfer?.files || [])
|
||||
if (files.length) uploadFiles(files)
|
||||
}
|
||||
|
||||
const doUpload = async (file: File) => {
|
||||
const uploadFiles = (files: File[]) => {
|
||||
uploadError.value = ''
|
||||
const ext = '.' + (file.name.split('.').pop() || '').toLowerCase()
|
||||
if (!['.md', '.txt', '.pdf', '.doc', '.docx', '.xlsx'].includes(ext)) {
|
||||
uploadError.value = `不支持的类型:${ext},仅支持 md/txt/pdf/doc/docx/xlsx`
|
||||
return
|
||||
}
|
||||
if (!avatarId.value) {
|
||||
uploadError.value = '请先创建数字分身'
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
for (const file of files) {
|
||||
const ext = '.' + (file.name.split('.').pop() || '').toLowerCase()
|
||||
if (!['.md', '.txt', '.pdf', '.doc', '.docx', '.xlsx'].includes(ext)) {
|
||||
uploadError.value = `不支持的类型:${ext},仅支持 md/txt/pdf/doc/docx/xlsx`
|
||||
continue
|
||||
}
|
||||
void uploadOne(file, ext)
|
||||
}
|
||||
}
|
||||
|
||||
const uploadOne = async (file: File, ext: string) => {
|
||||
if (!avatarId.value) return
|
||||
const localId = `upload-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
const card = {
|
||||
id: localId,
|
||||
filename: file.name,
|
||||
fileType: ext.slice(1),
|
||||
fileSize: file.size,
|
||||
createdAt: new Date().toISOString(),
|
||||
localUploading: true,
|
||||
localOnly: true,
|
||||
uploadProgress: 0,
|
||||
errorMessage: ''
|
||||
}
|
||||
pendingUploads.value.unshift(card)
|
||||
try {
|
||||
await uploadKnowledgeDoc(avatarId.value, file)
|
||||
await loadDocs()
|
||||
const created: any = await uploadKnowledgeDoc(avatarId.value, file, (loaded, total) => {
|
||||
const current = pendingUploads.value.find((doc) => doc.id === localId)
|
||||
if (current) current.uploadProgress = Math.min(99, Math.round((loaded / Math.max(1, total)) * 100))
|
||||
})
|
||||
pendingUploads.value = pendingUploads.value.filter((doc) => doc.id !== localId)
|
||||
docs.value = [created, ...docs.value.filter((doc) => doc.id !== created.id)]
|
||||
startDocumentPolling()
|
||||
} catch (e: any) {
|
||||
uploadError.value = e?.message || '上传失败'
|
||||
} finally {
|
||||
uploading.value = false
|
||||
const current = pendingUploads.value.find((doc) => doc.id === localId)
|
||||
if (current) {
|
||||
current.localUploading = false
|
||||
current.errorMessage = e?.message || '上传失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +262,11 @@ const retryDoc = async (id: string) => {
|
||||
}
|
||||
|
||||
const removeDoc = async (id: string) => {
|
||||
const local = pendingUploads.value.find((doc) => doc.id === id)
|
||||
if (local?.localOnly) {
|
||||
pendingUploads.value = pendingUploads.value.filter((doc) => doc.id !== id)
|
||||
return
|
||||
}
|
||||
if (!avatarId.value) return
|
||||
await deleteKnowledgeDoc(avatarId.value, id)
|
||||
await loadDocs()
|
||||
@@ -338,6 +386,8 @@ onUnmounted(stopDocumentPolling)
|
||||
.status-pill.missing { color: #B91C1C; background: #FEF2F2; }
|
||||
.status-pill.failed { color: #B91C1C; background: #FEF2F2; }
|
||||
.card-meta, .card-detail { margin: 5px 0 0; color: #9398AE; font-size: 11px; line-height: 1.4; }.card-detail { color: #8B6B58; }
|
||||
.progress-track { width: 100%; height: 4px; margin-top: 8px; overflow: hidden; border-radius: 999px; background: #FDE7D1; }
|
||||
.progress-fill { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #FB923C, #F97316); transition: width .25s ease; }
|
||||
.card-actions { flex: 0 0 auto; display: flex; flex-direction: column; align-items: stretch; gap: 6px; }
|
||||
.card-delete, .card-retry { align-self: center; border: 0; border-radius: 8px; padding: 7px 9px; font-size: 12px; cursor: pointer; white-space: nowrap; }
|
||||
.card-delete { color: #EF4444; background: #FEF2F2; }
|
||||
|
||||
Reference in New Issue
Block a user