feat(wiki): drag-over visual, optimistic UI, and progress bar for raw material uploads

- Drag-over highlight (orange border + shadow + arrow icon) on the upload
  zone, using dragCounter to prevent flicker over nested children
- Optimistic list items appear immediately on drop/select with UPLOADING
  badge and progress bar, before the HTTP request completes
- Wire axios onUploadProgress through api.uploadRaw → store.uploadRawFile
  so the progress bar tracks real byte transfer (0–100%), with an
  indeterminate shimmer until the first tick
- try/catch around every upload: on failure, placeholder flips to an error
  state with ElMessage.error toast and a × dismiss button
- Upload all dropped/selected files concurrently via Promise.all
- i18n keys added (zh-CN + en-US): dropToUpload, uploading, uploadFailed,
  status.uploading, progress.uploading
This commit is contained in:
matevip 2026-04-24 15:22:32 +08:00
parent 72a0b5fc81
commit 45bae0c8ab
5 changed files with 194 additions and 20 deletions

View File

@ -422,9 +422,12 @@ export const wikiApi = {
listRaw: (kbId: number) => http.get(`/wiki/knowledge-bases/${kbId}/raw`),
addRawText: (kbId: number, data: { title: string; content: string }) =>
http.post(`/wiki/knowledge-bases/${kbId}/raw/text`, data),
uploadRaw: (kbId: number, formData: FormData) =>
uploadRaw: (kbId: number, formData: FormData, onProgress?: (pct: number) => void) =>
http.post(`/wiki/knowledge-bases/${kbId}/raw/upload`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: onProgress
? (e) => { if (e.total) onProgress(Math.round((e.loaded / e.total) * 100)) }
: undefined,
}),
deleteRaw: (kbId: number, rawId: number) =>
http.delete(`/wiki/knowledge-bases/${kbId}/raw/${rawId}`),

View File

@ -1222,6 +1222,9 @@ export default {
config: 'Config',
searchPages: 'Search pages...',
dropFiles: 'Drop files here or click to upload',
dropToUpload: 'Release to upload',
uploading: 'Uploading…',
uploadFailed: 'Upload failed: {name}',
addText: 'Add Text',
noRawMaterials: 'No raw materials yet',
reprocess: 'Reprocess',
@ -1244,6 +1247,7 @@ export default {
saving: 'Saving...',
status: {
active: 'ACTIVE',
uploading: 'UPLOADING',
processing: 'PROCESSING',
error: 'ERROR',
pending: 'PENDING',
@ -1253,6 +1257,7 @@ export default {
},
progress: {
preparing: 'Preparing…',
uploading: 'Uploading {pct}%',
},
jobStage: {
queued: 'Queued',

View File

@ -1232,6 +1232,9 @@ export default {
config: '处理配置',
searchPages: '搜索页面...',
dropFiles: '拖拽文件到此处或点击上传',
dropToUpload: '松开即开始上传',
uploading: '上传中…',
uploadFailed: '上传失败:{name}',
addText: '添加文本',
noRawMaterials: '暂无原始材料',
reprocess: '重新处理',
@ -1254,6 +1257,7 @@ export default {
saving: '保存中...',
status: {
active: '启用',
uploading: '上传中',
processing: '处理中',
error: '错误',
pending: '待处理',
@ -1263,6 +1267,7 @@ export default {
},
progress: {
preparing: '准备中…',
uploading: '上传 {pct}%',
},
jobStage: {
queued: '排队中',

View File

@ -135,10 +135,10 @@ export const useWikiStore = defineStore('wiki', () => {
return raw
}
async function uploadRawFile(kbId: number, file: File) {
async function uploadRawFile(kbId: number, file: File, onProgress?: (pct: number) => void) {
const formData = new FormData()
formData.append('file', file)
const res: any = await wikiApi.uploadRaw(kbId, formData)
const res: any = await wikiApi.uploadRaw(kbId, formData, onProgress)
const raw = res.data || res
// Dedup: if backend returned an existing record, replace it in the list instead of adding a duplicate
const existingIdx = rawMaterials.value.findIndex(r => r.id === raw.id)

View File

@ -2,14 +2,36 @@
<div class="raw-panel">
<!-- Upload + Add text row -->
<div class="upload-row">
<div class="upload-zone" @click="triggerFileInput" @dragover.prevent @drop.prevent="handleDrop">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<div
class="upload-zone"
:class="{ 'is-dragging': isDragging, 'is-uploading': uploadingFiles.length > 0 }"
@click="triggerFileInput"
@dragover.prevent
@dragenter.prevent="onDragEnter"
@dragleave.prevent="onDragLeave"
@drop.prevent="handleDrop"
>
<!-- Spinner while uploading -->
<svg v-if="uploadingFiles.length > 0" class="upload-spinner" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>
<!-- Arrow-up icon in drag-over state -->
<svg v-else-if="isDragging" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="21"/>
</svg>
<!-- Default upload icon -->
<svg v-else width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<div class="upload-text">
<span class="upload-label">{{ t('wiki.dropFiles') }}</span>
<span class="upload-label">
<template v-if="uploadingFiles.length > 0">{{ t('wiki.uploading') }}</template>
<template v-else-if="isDragging">{{ t('wiki.dropToUpload') }}</template>
<template v-else>{{ t('wiki.dropFiles') }}</template>
</span>
<span class="upload-hint">.txt, .md, .pdf, .docx</span>
</div>
</div>
@ -50,11 +72,59 @@
<!-- Raw materials list -->
<div class="raw-list">
<h4 class="raw-list-title">
{{ t('wiki.rawMaterials') }} ({{ store.rawMaterials.length }})
{{ t('wiki.rawMaterials') }} ({{ store.rawMaterials.length + uploadingFiles.length }})
</h4>
<div v-if="store.rawMaterials.length === 0" class="empty-hint">
<div v-if="store.rawMaterials.length === 0 && uploadingFiles.length === 0" class="empty-hint">
{{ t('wiki.noRawMaterials') }}
</div>
<!-- Optimistic uploading items shown at the top -->
<div
v-for="uf in uploadingFiles"
:key="uf.tempId"
class="raw-item raw-item--uploading"
>
<div class="raw-item-row">
<div class="raw-item-info">
<span class="raw-item-title">{{ uf.name }}</span>
</div>
<div class="raw-item-meta">
<span v-if="uf.status === 'error'" class="status-badge failed">{{ t('wiki.status.failed') }}</span>
<span v-else class="status-badge uploading">{{ t('wiki.status.uploading') }}</span>
<span
v-if="uf.status === 'error' && uf.errorMsg"
class="error-hint" :title="uf.errorMsg"
>{{ uf.errorMsg }}</span>
</div>
<div class="raw-item-actions">
<!-- Dismiss error item -->
<button
v-if="uf.status === 'error'"
class="btn-icon btn-icon-danger"
:title="t('common.delete')"
@click="removeUploadingFile(uf.tempId)"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
</div>
<!-- HTTP upload progress bar -->
<div v-if="uf.status !== 'error'" class="raw-progress">
<div class="raw-progress-track">
<div
class="raw-progress-fill"
:class="{ indeterminate: uf.httpPct === 0 }"
:style="uf.httpPct > 0 ? { width: uf.httpPct + '%' } : {}"
></div>
</div>
<span class="raw-progress-label">
{{ uf.httpPct > 0 ? t('wiki.progress.uploading', { pct: uf.httpPct }) : t('wiki.progress.preparing') }}
</span>
</div>
</div>
<div
v-for="raw in store.rawMaterials"
:key="raw.id"
@ -171,6 +241,7 @@
<script setup lang="ts">
import { ref, reactive, computed, watch, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { useWikiStore } from '@/stores/useWikiStore'
import { wikiApi } from '@/api/index'
import JobStageBar from './JobStageBar.vue'
@ -250,7 +321,7 @@ function openSse(kbId: number) {
} catch { /* ignore */ }
})
es.onerror = () => {
// EventSource log
// Browser EventSource auto-reconnects; just log
// console.debug('Wiki SSE error/reconnect', kbId)
}
}
@ -267,9 +338,9 @@ watch(
() => [hasProcessing.value, store.currentKB?.id] as const,
([active, kbId]) => {
if (active && kbId != null) {
// SSE
// SSE main channel
if (activeKbId !== kbId) openSse(kbId)
// 60s
// 60s fallback polling
if (fallbackTimer == null) {
fallbackTimer = window.setInterval(() => {
if (store.currentKB) store.fetchRawMaterials(store.currentKB.id)
@ -351,6 +422,68 @@ const dirPath = ref(store.currentKB?.sourceDirectory || '')
const scanning = ref(false)
const scanResult = ref<{ scanned: number; added: number; skipped: number } | null>(null)
// Drag-over state
// Use a counter to handle nested dragenter/dragleave without flickering.
const isDragging = ref(false)
let dragCounter = 0
function onDragEnter() {
dragCounter++
isDragging.value = true
}
function onDragLeave() {
dragCounter--
if (dragCounter <= 0) {
dragCounter = 0
isDragging.value = false
}
}
// Optimistic upload items
interface UploadingFile {
tempId: string
name: string
httpPct: number
status: 'uploading' | 'error'
errorMsg?: string
}
const uploadingFiles = ref<UploadingFile[]>([])
let tempIdCounter = 0
function addUploadingFile(name: string): UploadingFile {
const item: UploadingFile = {
tempId: `upload-${++tempIdCounter}`,
name,
httpPct: 0,
status: 'uploading',
}
uploadingFiles.value.push(item)
return item
}
function removeUploadingFile(tempId: string) {
const idx = uploadingFiles.value.findIndex(f => f.tempId === tempId)
if (idx >= 0) uploadingFiles.value.splice(idx, 1)
}
// Upload helpers
async function uploadFile(kbId: number, file: File) {
const item = addUploadingFile(file.name)
try {
await store.uploadRawFile(kbId, file, (pct) => {
item.httpPct = pct
})
// Success: real item was added to store.rawMaterials, remove the optimistic placeholder
removeUploadingFile(item.tempId)
} catch (err: any) {
item.status = 'error'
item.errorMsg = err?.response?.data?.message || err?.message || t('wiki.uploadFailed', { name: file.name })
ElMessage.error(t('wiki.uploadFailed', { name: file.name }))
}
}
function triggerFileInput() {
fileInput.value?.click()
}
@ -358,17 +491,19 @@ function triggerFileInput() {
async function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement
if (!input.files || !store.currentKB) return
for (const file of Array.from(input.files)) {
await store.uploadRawFile(store.currentKB.id, file)
}
const kbId = store.currentKB.id
// Upload all files concurrently
await Promise.all(Array.from(input.files).map(f => uploadFile(kbId, f)))
input.value = ''
}
async function handleDrop(event: DragEvent) {
// Reset drag state
dragCounter = 0
isDragging.value = false
if (!event.dataTransfer?.files || !store.currentKB) return
for (const file of Array.from(event.dataTransfer.files)) {
await store.uploadRawFile(store.currentKB.id, file)
}
const kbId = store.currentKB.id
await Promise.all(Array.from(event.dataTransfer.files).map(f => uploadFile(kbId, f)))
}
async function handleAddText() {
@ -431,9 +566,9 @@ async function handleScanDir() {
scanning.value = true
scanResult.value = null
try {
//
// Save directory path first
await wikiApi.setSourceDirectory(store.currentKB.id, dirPath.value.trim())
//
// Trigger scan
const result = await store.scanDirectory(store.currentKB.id)
scanResult.value = result
} catch (e: any) {
@ -474,19 +609,42 @@ async function handleScanDir() {
border-radius: 16px;
padding: 18px 20px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
display: flex;
align-items: center;
gap: 12px;
color: var(--mc-text-tertiary);
}
.upload-zone:hover { border-color: var(--mc-primary); background: var(--mc-primary-bg); }
.upload-zone.is-dragging {
border-color: var(--mc-primary);
background: var(--mc-primary-bg);
box-shadow: 0 0 0 3px rgba(217,119,87,0.15);
color: var(--mc-primary);
}
.upload-zone.is-uploading {
border-color: var(--mc-primary);
background: var(--mc-primary-bg);
cursor: default;
pointer-events: none;
}
.upload-zone svg { flex-shrink: 0; }
.upload-text { display: flex; flex-direction: column; gap: 2px; }
.upload-label { font-size: 14px; color: var(--mc-text-secondary); }
.upload-hint { font-size: 12px; color: var(--mc-text-tertiary); }
.add-text-btn { flex-shrink: 0; }
/* Spinner animation for uploading state */
.upload-spinner {
flex-shrink: 0;
animation: spin 1s linear infinite;
color: var(--mc-primary);
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Raw list */
.raw-list { display: flex; flex-direction: column; gap: 8px; padding-top: 4px; }
.raw-list-title { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--mc-text-tertiary); margin-bottom: 4px; }
@ -495,6 +653,8 @@ async function handleScanDir() {
.raw-item { display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; background: linear-gradient(180deg, var(--mc-bg-elevated), var(--mc-bg-muted)); border: 1px solid var(--mc-border-light); border-radius: 14px; font-size: 13px; transition: border-color 0.15s, transform 0.15s; cursor: pointer; }
.raw-item:hover { border-color: var(--mc-border); transform: translateY(-1px); }
.raw-item--active { border-color: var(--mc-primary) !important; background: var(--mc-primary-bg) !important; transform: translateY(-1px); }
.raw-item--uploading { cursor: default; opacity: 0.85; }
.raw-item--uploading:hover { transform: none; border-color: var(--mc-border-light); }
.raw-item-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.raw-item-info { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
@ -530,6 +690,7 @@ async function handleScanDir() {
/* Status badges */
.status-badge { font-size: 10px; padding: 2px 8px; border-radius: 9999px; text-transform: uppercase; font-weight: 500; letter-spacing: 0.02em; }
.status-badge.pending { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }
.status-badge.uploading { background: rgba(59, 130, 246, 0.12); color: #3b82f6; }
.status-badge.processing { background: var(--mc-primary-bg); color: var(--mc-primary); }
.status-badge.completed { background: rgba(90, 138, 90, 0.15); color: var(--mc-success); }
.status-badge.partial { background: rgba(217, 119, 87, 0.15); color: var(--mc-primary); }