mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +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`),
|
listRaw: (kbId: number) => http.get(`/wiki/knowledge-bases/${kbId}/raw`),
|
||||||
addRawText: (kbId: number, data: { title: string; content: string }) =>
|
addRawText: (kbId: number, data: { title: string; content: string }) =>
|
||||||
http.post(`/wiki/knowledge-bases/${kbId}/raw/text`, data),
|
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, {
|
http.post(`/wiki/knowledge-bases/${kbId}/raw/upload`, formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
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) =>
|
deleteRaw: (kbId: number, rawId: number) =>
|
||||||
http.delete(`/wiki/knowledge-bases/${kbId}/raw/${rawId}`),
|
http.delete(`/wiki/knowledge-bases/${kbId}/raw/${rawId}`),
|
||||||
|
|||||||
@ -1222,6 +1222,9 @@ export default {
|
|||||||
config: 'Config',
|
config: 'Config',
|
||||||
searchPages: 'Search pages...',
|
searchPages: 'Search pages...',
|
||||||
dropFiles: 'Drop files here or click to upload',
|
dropFiles: 'Drop files here or click to upload',
|
||||||
|
dropToUpload: 'Release to upload',
|
||||||
|
uploading: 'Uploading…',
|
||||||
|
uploadFailed: 'Upload failed: {name}',
|
||||||
addText: 'Add Text',
|
addText: 'Add Text',
|
||||||
noRawMaterials: 'No raw materials yet',
|
noRawMaterials: 'No raw materials yet',
|
||||||
reprocess: 'Reprocess',
|
reprocess: 'Reprocess',
|
||||||
@ -1244,6 +1247,7 @@ export default {
|
|||||||
saving: 'Saving...',
|
saving: 'Saving...',
|
||||||
status: {
|
status: {
|
||||||
active: 'ACTIVE',
|
active: 'ACTIVE',
|
||||||
|
uploading: 'UPLOADING',
|
||||||
processing: 'PROCESSING',
|
processing: 'PROCESSING',
|
||||||
error: 'ERROR',
|
error: 'ERROR',
|
||||||
pending: 'PENDING',
|
pending: 'PENDING',
|
||||||
@ -1253,6 +1257,7 @@ export default {
|
|||||||
},
|
},
|
||||||
progress: {
|
progress: {
|
||||||
preparing: 'Preparing…',
|
preparing: 'Preparing…',
|
||||||
|
uploading: 'Uploading {pct}%',
|
||||||
},
|
},
|
||||||
jobStage: {
|
jobStage: {
|
||||||
queued: 'Queued',
|
queued: 'Queued',
|
||||||
|
|||||||
@ -1232,6 +1232,9 @@ export default {
|
|||||||
config: '处理配置',
|
config: '处理配置',
|
||||||
searchPages: '搜索页面...',
|
searchPages: '搜索页面...',
|
||||||
dropFiles: '拖拽文件到此处或点击上传',
|
dropFiles: '拖拽文件到此处或点击上传',
|
||||||
|
dropToUpload: '松开即开始上传',
|
||||||
|
uploading: '上传中…',
|
||||||
|
uploadFailed: '上传失败:{name}',
|
||||||
addText: '添加文本',
|
addText: '添加文本',
|
||||||
noRawMaterials: '暂无原始材料',
|
noRawMaterials: '暂无原始材料',
|
||||||
reprocess: '重新处理',
|
reprocess: '重新处理',
|
||||||
@ -1254,6 +1257,7 @@ export default {
|
|||||||
saving: '保存中...',
|
saving: '保存中...',
|
||||||
status: {
|
status: {
|
||||||
active: '启用',
|
active: '启用',
|
||||||
|
uploading: '上传中',
|
||||||
processing: '处理中',
|
processing: '处理中',
|
||||||
error: '错误',
|
error: '错误',
|
||||||
pending: '待处理',
|
pending: '待处理',
|
||||||
@ -1263,6 +1267,7 @@ export default {
|
|||||||
},
|
},
|
||||||
progress: {
|
progress: {
|
||||||
preparing: '准备中…',
|
preparing: '准备中…',
|
||||||
|
uploading: '上传 {pct}%',
|
||||||
},
|
},
|
||||||
jobStage: {
|
jobStage: {
|
||||||
queued: '排队中',
|
queued: '排队中',
|
||||||
|
|||||||
@ -135,10 +135,10 @@ export const useWikiStore = defineStore('wiki', () => {
|
|||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadRawFile(kbId: number, file: File) {
|
async function uploadRawFile(kbId: number, file: File, onProgress?: (pct: number) => void) {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
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
|
const raw = res.data || res
|
||||||
// Dedup: if backend returned an existing record, replace it in the list instead of adding a duplicate
|
// 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)
|
const existingIdx = rawMaterials.value.findIndex(r => r.id === raw.id)
|
||||||
|
|||||||
@ -2,14 +2,36 @@
|
|||||||
<div class="raw-panel">
|
<div class="raw-panel">
|
||||||
<!-- Upload + Add text row -->
|
<!-- Upload + Add text row -->
|
||||||
<div class="upload-row">
|
<div class="upload-row">
|
||||||
<div class="upload-zone" @click="triggerFileInput" @dragover.prevent @drop.prevent="handleDrop">
|
<div
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
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"/>
|
<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"/>
|
<polyline points="17 8 12 3 7 8"/>
|
||||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="upload-text">
|
<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>
|
<span class="upload-hint">.txt, .md, .pdf, .docx</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -50,11 +72,59 @@
|
|||||||
<!-- Raw materials list -->
|
<!-- Raw materials list -->
|
||||||
<div class="raw-list">
|
<div class="raw-list">
|
||||||
<h4 class="raw-list-title">
|
<h4 class="raw-list-title">
|
||||||
{{ t('wiki.rawMaterials') }} ({{ store.rawMaterials.length }})
|
{{ t('wiki.rawMaterials') }} ({{ store.rawMaterials.length + uploadingFiles.length }})
|
||||||
</h4>
|
</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') }}
|
{{ t('wiki.noRawMaterials') }}
|
||||||
</div>
|
</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
|
<div
|
||||||
v-for="raw in store.rawMaterials"
|
v-for="raw in store.rawMaterials"
|
||||||
:key="raw.id"
|
:key="raw.id"
|
||||||
@ -171,6 +241,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, watch, onBeforeUnmount } from 'vue'
|
import { ref, reactive, computed, watch, onBeforeUnmount } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
import { useWikiStore } from '@/stores/useWikiStore'
|
import { useWikiStore } from '@/stores/useWikiStore'
|
||||||
import { wikiApi } from '@/api/index'
|
import { wikiApi } from '@/api/index'
|
||||||
import JobStageBar from './JobStageBar.vue'
|
import JobStageBar from './JobStageBar.vue'
|
||||||
@ -250,7 +321,7 @@ function openSse(kbId: number) {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
})
|
})
|
||||||
es.onerror = () => {
|
es.onerror = () => {
|
||||||
// 浏览器 EventSource 会自动重连;这里仅 log
|
// Browser EventSource auto-reconnects; just log
|
||||||
// console.debug('Wiki SSE error/reconnect', kbId)
|
// console.debug('Wiki SSE error/reconnect', kbId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -267,9 +338,9 @@ watch(
|
|||||||
() => [hasProcessing.value, store.currentKB?.id] as const,
|
() => [hasProcessing.value, store.currentKB?.id] as const,
|
||||||
([active, kbId]) => {
|
([active, kbId]) => {
|
||||||
if (active && kbId != null) {
|
if (active && kbId != null) {
|
||||||
// SSE 主通道
|
// SSE main channel
|
||||||
if (activeKbId !== kbId) openSse(kbId)
|
if (activeKbId !== kbId) openSse(kbId)
|
||||||
// 60s 兜底拉取
|
// 60s fallback polling
|
||||||
if (fallbackTimer == null) {
|
if (fallbackTimer == null) {
|
||||||
fallbackTimer = window.setInterval(() => {
|
fallbackTimer = window.setInterval(() => {
|
||||||
if (store.currentKB) store.fetchRawMaterials(store.currentKB.id)
|
if (store.currentKB) store.fetchRawMaterials(store.currentKB.id)
|
||||||
@ -351,6 +422,68 @@ const dirPath = ref(store.currentKB?.sourceDirectory || '')
|
|||||||
const scanning = ref(false)
|
const scanning = ref(false)
|
||||||
const scanResult = ref<{ scanned: number; added: number; skipped: number } | null>(null)
|
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() {
|
function triggerFileInput() {
|
||||||
fileInput.value?.click()
|
fileInput.value?.click()
|
||||||
}
|
}
|
||||||
@ -358,17 +491,19 @@ function triggerFileInput() {
|
|||||||
async function handleFileSelect(event: Event) {
|
async function handleFileSelect(event: Event) {
|
||||||
const input = event.target as HTMLInputElement
|
const input = event.target as HTMLInputElement
|
||||||
if (!input.files || !store.currentKB) return
|
if (!input.files || !store.currentKB) return
|
||||||
for (const file of Array.from(input.files)) {
|
const kbId = store.currentKB.id
|
||||||
await store.uploadRawFile(store.currentKB.id, file)
|
// Upload all files concurrently
|
||||||
}
|
await Promise.all(Array.from(input.files).map(f => uploadFile(kbId, f)))
|
||||||
input.value = ''
|
input.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDrop(event: DragEvent) {
|
async function handleDrop(event: DragEvent) {
|
||||||
|
// Reset drag state
|
||||||
|
dragCounter = 0
|
||||||
|
isDragging.value = false
|
||||||
if (!event.dataTransfer?.files || !store.currentKB) return
|
if (!event.dataTransfer?.files || !store.currentKB) return
|
||||||
for (const file of Array.from(event.dataTransfer.files)) {
|
const kbId = store.currentKB.id
|
||||||
await store.uploadRawFile(store.currentKB.id, file)
|
await Promise.all(Array.from(event.dataTransfer.files).map(f => uploadFile(kbId, f)))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddText() {
|
async function handleAddText() {
|
||||||
@ -431,9 +566,9 @@ async function handleScanDir() {
|
|||||||
scanning.value = true
|
scanning.value = true
|
||||||
scanResult.value = null
|
scanResult.value = null
|
||||||
try {
|
try {
|
||||||
// 先保存目录路径
|
// Save directory path first
|
||||||
await wikiApi.setSourceDirectory(store.currentKB.id, dirPath.value.trim())
|
await wikiApi.setSourceDirectory(store.currentKB.id, dirPath.value.trim())
|
||||||
// 触发扫描
|
// Trigger scan
|
||||||
const result = await store.scanDirectory(store.currentKB.id)
|
const result = await store.scanDirectory(store.currentKB.id)
|
||||||
scanResult.value = result
|
scanResult.value = result
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@ -474,19 +609,42 @@ async function handleScanDir() {
|
|||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 18px 20px;
|
padding: 18px 20px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.15s, background 0.15s;
|
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
color: var(--mc-text-tertiary);
|
color: var(--mc-text-tertiary);
|
||||||
}
|
}
|
||||||
.upload-zone:hover { border-color: var(--mc-primary); background: var(--mc-primary-bg); }
|
.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-zone svg { flex-shrink: 0; }
|
||||||
.upload-text { display: flex; flex-direction: column; gap: 2px; }
|
.upload-text { display: flex; flex-direction: column; gap: 2px; }
|
||||||
.upload-label { font-size: 14px; color: var(--mc-text-secondary); }
|
.upload-label { font-size: 14px; color: var(--mc-text-secondary); }
|
||||||
.upload-hint { font-size: 12px; color: var(--mc-text-tertiary); }
|
.upload-hint { font-size: 12px; color: var(--mc-text-tertiary); }
|
||||||
.add-text-btn { flex-shrink: 0; }
|
.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 */
|
||||||
.raw-list { display: flex; flex-direction: column; gap: 8px; padding-top: 4px; }
|
.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; }
|
.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 { 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: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--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-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; }
|
.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 badges */
|
||||||
.status-badge { font-size: 10px; padding: 2px 8px; border-radius: 9999px; text-transform: uppercase; font-weight: 500; letter-spacing: 0.02em; }
|
.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.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.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.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); }
|
.status-badge.partial { background: rgba(217, 119, 87, 0.15); color: var(--mc-primary); }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user