mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
72a0b5fc81
commit
45bae0c8ab
@ -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}`),
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: '排队中',
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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); }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user