diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index fec88161..25435e40 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -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}`), diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 18f8c8b3..a11e20ee 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -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', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index bb5aae20..e014e338 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -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: '排队中', diff --git a/mateclaw-ui/src/stores/useWikiStore.ts b/mateclaw-ui/src/stores/useWikiStore.ts index f07d4112..32b07716 100644 --- a/mateclaw-ui/src/stores/useWikiStore.ts +++ b/mateclaw-ui/src/stores/useWikiStore.ts @@ -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) diff --git a/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue b/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue index 266d0491..e3d9c2be 100644 --- a/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue +++ b/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue @@ -2,14 +2,36 @@
-
- +
+ + + + + + + + + + +
- {{ t('wiki.dropFiles') }} + + + + + .txt, .md, .pdf, .docx
@@ -50,11 +72,59 @@

- {{ t('wiki.rawMaterials') }} ({{ store.rawMaterials.length }}) + {{ t('wiki.rawMaterials') }} ({{ store.rawMaterials.length + uploadingFiles.length }})

-
+
{{ t('wiki.noRawMaterials') }}
+ + +
+
+
+ {{ uf.name }} +
+
+ {{ t('wiki.status.failed') }} + {{ t('wiki.status.uploading') }} + {{ uf.errorMsg }} +
+
+ + +
+
+ +
+
+
+
+ + {{ uf.httpPct > 0 ? t('wiki.progress.uploading', { pct: uf.httpPct }) : t('wiki.progress.preparing') }} + +
+
+
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([]) +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); }