Files
YG-Datasets/frontend/src/views/project/FileManage.vue
Developer df70c09fe2 feat(frontend): 优化文件管理上传流程和 UI 体验
- 上传后立即显示文件列表,无需等待
- 添加轮询机制自动更新处理状态
- 移除固定高度限制,表格高度自适应
- 优化动画只在首次加载时播放,避免刷新闪烁
- 上传中状态隐藏空状态显示

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 16:08:12 +08:00

1523 lines
34 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="file-manage">
<!-- Header -->
<div class="page-header">
<div class="header-left">
<h2 class="page-title">文件管理</h2>
<p class="page-subtitle">管理您的文档集合</p>
</div>
<div class="header-actions">
<el-button type="primary" @click="handleUpload" class="upload-btn">
<el-icon><Upload /></el-icon>
<span>上传文件</span>
</el-button>
</div>
</div>
<!-- Stats Cards -->
<div class="stats-grid">
<div
class="stat-card stat-total"
:class="{ active: filterStatus === '' }"
@click="filterStatus = ''"
>
<div class="stat-glow"></div>
<div class="stat-inner">
<div class="stat-icon-wrap">
<el-icon size="24"><Document /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ files.length }}</span>
<span class="stat-label">总文件数</span>
</div>
</div>
</div>
<div
class="stat-card stat-completed"
:class="{ active: filterStatus === 'completed' }"
@click="filterStatus = filterStatus === 'completed' ? '' : 'completed'"
>
<div class="stat-glow"></div>
<div class="stat-inner">
<div class="stat-icon-wrap">
<el-icon size="24"><CircleCheckFilled /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ completedFiles }}</span>
<span class="stat-label">已完成</span>
</div>
</div>
</div>
<div
class="stat-card stat-processing"
:class="{ active: filterStatus === 'processing' }"
@click="filterStatus = filterStatus === 'processing' ? '' : 'processing'"
>
<div class="stat-glow"></div>
<div class="stat-inner">
<div class="stat-icon-wrap">
<el-icon size="24"><Loading /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ processingFiles.length }}</span>
<span class="stat-label">处理中</span>
</div>
</div>
</div>
<div
class="stat-card stat-failed"
:class="{ active: filterStatus === 'failed' }"
@click="filterStatus = filterStatus === 'failed' ? '' : 'failed'"
>
<div class="stat-glow"></div>
<div class="stat-inner">
<div class="stat-icon-wrap">
<el-icon size="24"><CircleCloseFilled /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ failedFiles }}</span>
<span class="stat-label">失败</span>
</div>
</div>
</div>
</div>
<!-- File List Container -->
<div class="file-container" v-loading="loading && isInitialLoad">
<!-- Empty State -->
<div v-if="!loading && !isInitialLoad && filteredFiles.length === 0 && !uploading" class="empty-state">
<div class="empty-illustration">
<div class="orbit orbit-1"></div>
<div class="orbit orbit-2"></div>
<div class="orbit orbit-3"></div>
<div class="empty-core">
<el-icon size="40"><FolderOpened /></el-icon>
</div>
</div>
<h3 class="empty-title">暂无文件</h3>
<p class="empty-desc">上传您的第一个文档开启智能处理之旅</p>
</div>
<!-- Files Table -->
<div v-else class="files-table-wrapper">
<!-- Table Header -->
<div class="table-header">
<div class="table-select">
<el-checkbox
:model-value="isAllSelected"
@change="toggleSelectAll"
class="select-all"
>
<span v-if="selectedCount > 0" class="selected-text">已选择 {{ selectedCount }} </span>
<span v-else>全选</span>
</el-checkbox>
</div>
<div class="table-actions" v-if="selectedCount > 0">
<el-button type="danger" size="small" plain @click="batchDelete" class="batch-delete-btn">
<el-icon><Delete /></el-icon>
<span>批量删除</span>
</el-button>
</div>
</div>
<!-- Table Body -->
<div class="files-table">
<div
v-for="(file, index) in filteredFiles"
:key="file.id"
class="file-row"
:class="{
'is-selected': isSelected(file.id),
'is-processing': file.status === 'processing',
'row-animated': isInitialLoad
}"
:style="{ '--delay': index * 0.04 + 's' }"
>
<!-- Select Checkbox -->
<div class="col-select">
<el-checkbox
:model-value="isSelected(file.id)"
@change="toggleSelect(file.id)"
/>
</div>
<!-- File Icon -->
<div class="col-icon">
<div class="file-type-icon" :style="{ '--type-color': getTypeColor(file.file_type) }">
<el-icon size="18">
<component :is="getFileIcon(file.file_type)" />
</el-icon>
</div>
</div>
<!-- File Name -->
<div class="col-name">
<span class="filename-text">{{ file.filename }}</span>
<span class="file-ext">{{ getFileExt(file.filename) }}</span>
</div>
<!-- Size -->
<div class="col-size">
{{ formatSize(file.size) }}
</div>
<!-- Date -->
<div class="col-date">
{{ formatDate(file.created_at) }}
</div>
<!-- Status -->
<div class="col-status">
<div class="status-pill" :class="'status-' + file.status">
<span class="status-dot"></span>
<span class="status-text">{{ getStatusText(file.status) }}</span>
</div>
</div>
<!-- Actions -->
<div class="col-actions">
<el-tooltip content="预览" placement="top" v-if="file.status === 'completed'">
<el-button text size="small" class="action-btn preview" @click="handlePreview(file)">
<el-icon><View /></el-icon>
</el-button>
</el-tooltip>
<el-popconfirm title="确定删除此文件?" @confirm="handleDelete(file)">
<template #reference>
<el-button text size="small" class="action-btn delete">
<el-icon><Delete /></el-icon>
</el-button>
</template>
</el-popconfirm>
</div>
</div>
</div>
</div>
</div>
<!-- Upload Dialog -->
<el-dialog v-model="uploadDialogVisible" title="上传文件" width="520px" class="upload-dialog" :close-on-click-modal="false">
<div class="upload-area" @click="triggerUpload">
<el-upload
ref="uploadRef"
class="upload-component"
:auto-upload="false"
:limit="10"
:on-change="handleChange"
:on-remove="handleRemove"
:file-list="fileList"
drag
multiple
accept=".pdf,.docx,.doc,.xlsx,.xls,.csv,.epub,.md,.txt"
style="display: none;"
/>
<div class="upload-content">
<div class="upload-illustration">
<div class="upload-ring"></div>
<div class="upload-core">
<el-icon size="32"><UploadFilled /></el-icon>
</div>
</div>
<div class="upload-text">
拖拽文件到此处 <em>点击选择</em>
</div>
<div class="upload-hint">
支持 PDFDOCXExcelEPUBMarkdown 等格式
</div>
</div>
</div>
<!-- Selected Files -->
<div v-if="fileList.length > 0" class="selected-area">
<div class="selected-header">
<span>已选择 <strong>{{ fileList.length }}</strong> 个文件</span>
<el-button text size="small" @click="fileList = []">清空</el-button>
</div>
<div class="selected-list">
<div v-for="item in fileList" :key="item.uid" class="selected-item">
<el-icon size="14"><Document /></el-icon>
<span>{{ item.name }}</span>
</div>
</div>
</div>
<template #footer>
<el-button @click="uploadDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitUpload" :loading="uploading" :disabled="fileList.length === 0">
开始上传
</el-button>
</template>
</el-dialog>
</div>
<!-- Preview Modal -->
<Teleport to="body">
<!-- Backdrop -->
<Transition name="fade">
<div
v-if="previewVisible"
class="preview-backdrop"
@click="previewVisible = false"
></div>
</Transition>
<!-- Preview Modal -->
<Transition name="slide-right">
<div v-if="previewVisible" class="preview-modal">
<!-- Header -->
<div class="preview-header">
<div class="header-title">
<el-icon class="title-icon"><Document /></el-icon>
<span class="filename">{{ previewFile?.filename }}</span>
</div>
<el-button class="close-btn" text @click="previewVisible = false">
<el-icon><Close /></el-icon>
</el-button>
</div>
<!-- Tabs -->
<div class="preview-tabs-wrapper">
<div class="preview-tabs">
<button
class="tab-item"
:class="{ active: previewMode === 'source' }"
@click="switchPreviewMode('source')"
>
源文件
</button>
<button
class="tab-item"
:class="{ active: previewMode === 'markdown' }"
@click="switchPreviewMode('markdown')"
>
Markdown
</button>
<div class="tab-indicator" :class="{ 'at-right': previewMode === 'markdown' }"></div>
</div>
</div>
<!-- Content -->
<div class="preview-content" v-loading="previewLoading">
<iframe v-if="isPdfPreview && pdfDataUrl" :src="pdfDataUrl" class="pdf-viewer"></iframe>
<pre v-else-if="previewContent" class="code-content">{{ previewContent }}</pre>
<div v-else-if="!previewLoading && !isPdfPreview" class="preview-empty">
<el-icon size="32"><Document /></el-icon>
<span>暂无内容</span>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { fileApi } from '@/api'
const route = useRoute()
const projectId = computed(() => route.params.id)
const loading = ref(false)
const files = ref([])
const filterStatus = ref('')
const isInitialLoad = ref(true)
const filteredFiles = computed(() => {
if (!filterStatus.value) return files.value
return files.value.filter(f => f.status === filterStatus.value)
})
const uploadDialogVisible = ref(false)
const uploading = ref(false)
const uploadRef = ref(null)
const fileList = ref([])
// Multi-select
const selectedFiles = ref([])
const isAllSelected = computed(() => filteredFiles.value.length > 0 && selectedFiles.value.length === filteredFiles.value.length)
const selectedCount = computed(() => selectedFiles.value.length)
const toggleSelectAll = () => {
if (isAllSelected.value) {
selectedFiles.value = []
} else {
selectedFiles.value = filteredFiles.value.map(f => f.id)
}
}
const toggleSelect = (fileId) => {
const index = selectedFiles.value.indexOf(fileId)
if (index === -1) {
selectedFiles.value.push(fileId)
} else {
selectedFiles.value.splice(index, 1)
}
}
const isSelected = (fileId) => selectedFiles.value.includes(fileId)
const batchDelete = async () => {
if (selectedFiles.value.length === 0) return
try {
for (const fileId of selectedFiles.value) {
await fileApi.delete(projectId.value, fileId)
}
ElMessage.success(`已删除 ${selectedFiles.value.length} 个文件`)
selectedFiles.value = []
fetchFiles()
} catch (error) {
ElMessage.error('删除失败')
}
}
// Preview
const previewVisible = ref(false)
const previewFile = ref(null)
const previewContent = ref('')
const previewLoading = ref(false)
const previewMode = ref('source') // 'source' | 'markdown'
const isPdfPreview = ref(false)
const pdfDataUrl = ref('')
const completedFiles = computed(() => files.value.filter(f => f.status === 'completed').length)
const processingFiles = computed(() => files.value.filter(f => f.status === 'processing' || f.status === 'pending'))
const failedFiles = computed(() => files.value.filter(f => f.status === 'failed').length)
const fetchFiles = async () => {
const wasInitial = isInitialLoad.value
loading.value = true
try {
const res = await fileApi.list(projectId.value)
files.value = res || []
} catch (error) {
files.value = []
} finally {
loading.value = false
if (wasInitial) {
isInitialLoad.value = false
}
}
}
const handleUpload = () => {
fileList.value = []
uploadDialogVisible.value = true
}
const handleChange = (file, files) => { fileList.value = files }
const handleRemove = (file, files) => { fileList.value = files }
const triggerUpload = () => {
const input = uploadRef.value?.$el?.querySelector('input')
if (input) {
input.click()
}
}
const submitUpload = async () => {
if (fileList.value.length === 0) {
ElMessage.warning('请先选择文件')
return
}
// 保存上传前的文件数量
const prevFileCount = files.value.length
// 先关闭对话框
uploadDialogVisible.value = false
ElMessage.success('已开始上传文件')
// 设置上传状态,防止显示空状态
uploading.value = true
// 在后台逐个上传(不等待上传完成)
const uploadPromises = fileList.value.map(async (item) => {
try {
const formData = new FormData()
formData.append('file', item.raw)
await fileApi.upload(projectId.value, formData)
} catch (error) {
console.error('上传失败:', error)
}
})
// 立即刷新文件列表,显示新增的文件(状态为 processing
await fetchFiles()
// 如果之前没有文件需要等待上传的Promise完成后再刷新一次
if (prevFileCount === 0) {
await Promise.all(uploadPromises)
await fetchFiles()
}
// 持续轮询文件列表,直到没有 processing 状态的文件
const pollInterval = setInterval(async () => {
await fetchFiles()
// 检查是否还有处理中的文件
const hasProcessing = files.value.some(f => f.status === 'processing')
if (!hasProcessing) {
clearInterval(pollInterval)
uploading.value = false
}
}, 2000)
// 最多轮询60秒
setTimeout(() => {
clearInterval(pollInterval)
uploading.value = false
}, 60000)
}
const handleDelete = async (file) => {
try {
await fileApi.delete(projectId.value, file.id)
ElMessage.success('删除成功')
fetchFiles()
} catch (error) {
ElMessage.error('删除失败')
}
}
const handlePreview = async (file) => {
previewFile.value = file
previewVisible.value = true
previewContent.value = ''
previewLoading.value = true
previewMode.value = 'source'
try {
await loadPreviewContent()
} finally {
previewLoading.value = false
}
}
const loadPreviewContent = async () => {
if (!previewFile.value) return
previewLoading.value = true
isPdfPreview.value = false
pdfDataUrl.value = ''
try {
const endpoint = previewMode.value === 'source' ? 'raw' : 'content'
const response = await fetch(`/api/v1/projects/${projectId.value}/files/${previewFile.value.id}/${endpoint}`)
if (response.ok) {
const text = await response.text()
if (text.startsWith('data:application/pdf;base64,')) {
isPdfPreview.value = true
pdfDataUrl.value = text
previewContent.value = ''
} else {
previewContent.value = text
}
} else {
previewContent.value = '无法加载内容'
}
} catch (error) {
previewContent.value = '加载失败: ' + error.message
} finally {
previewLoading.value = false
}
}
const switchPreviewMode = async (mode) => {
previewMode.value = mode
await loadPreviewContent()
}
const getFileIcon = (type) => {
const map = { pdf: 'Document', docx: 'Document', xlsx: 'Grid', csv: 'Document', epub: 'Notebook', md: 'Document', txt: 'Document' }
return map[type] || 'Document'
}
const getTypeColor = (type) => {
const map = {
pdf: '#ef4444',
docx: '#3b82f6',
xlsx: '#22c55e',
csv: '#22c55e',
epub: '#f59e0b',
md: '#8b5cf6',
txt: '#6b7280'
}
return map[type] || '#6b7280'
}
const getFileExt = (filename) => {
if (!filename) return ''
const ext = filename.split('.').pop()?.toLowerCase()
return ext ? '.' + ext : ''
}
const getStatusText = (status) => {
const map = {
processing: '处理中',
completed: '已完成',
failed: '失败',
pending: '待处理'
}
return map[status] || '未知'
}
const formatSize = (bytes) => {
if (!bytes) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
const formatDate = (date) => {
if (!date) return ''
return new Date(date).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
onMounted(() => fetchFiles())
</script>
<style scoped>
/* ========================
CSS Variables
======================== */
.file-manage {
--accent-cyan: #00d4ff;
--accent-cyan-dim: rgba(0, 212, 255, 0.15);
--accent-cyan-glow: rgba(0, 212, 255, 0.4);
--bg-elevated: #0f1117;
--bg-card: #161920;
--bg-hover: #1c2029;
--border-subtle: rgba(255, 255, 255, 0.08);
--border-active: rgba(0, 212, 255, 0.3);
--text-secondary: #9ca3af;
--text-muted: #6b7280;
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
--radius-lg: 12px;
--radius-md: 8px;
--radius-sm: 6px;
padding: 28px 32px;
}
/* ========================
Header
======================== */
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 28px;
}
.header-left {
position: relative;
}
.page-title {
font-size: 28px;
font-weight: 700;
margin: 0;
background: linear-gradient(135deg, #ffffff 0%, var(--accent-cyan) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: -0.5px;
}
.page-subtitle {
font-size: 14px;
color: var(--text-muted);
margin: 6px 0 0;
}
.header-actions {
display: flex;
gap: 12px;
}
.upload-btn {
background: var(--accent-cyan) !important;
border: none !important;
color: #030407 !important;
font-weight: 600;
padding: 10px 22px;
border-radius: var(--radius-md);
box-shadow: 0 0 20px var(--accent-cyan-dim);
transition: all 0.2s ease;
}
.upload-btn:hover {
box-shadow: 0 0 35px var(--accent-cyan-glow);
transform: translateY(-1px);
}
.qa-btn {
background: var(--bg-card) !important;
border: 1px solid var(--border-subtle) !important;
color: var(--text-secondary) !important;
font-weight: 500;
padding: 10px 22px;
border-radius: var(--radius-md);
}
.qa-btn:hover:not(:disabled) {
background: var(--bg-hover) !important;
border-color: var(--accent-cyan) !important;
color: var(--accent-cyan) !important;
}
.qa-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ========================
Stats Grid
======================== */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
position: relative;
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
overflow: hidden;
transition: all 0.25s ease;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
opacity: 0;
transition: opacity 0.25s ease;
}
.stat-card:hover {
transform: translateY(-2px);
border-color: var(--border-active);
cursor: pointer;
}
.stat-card:hover::before {
opacity: 1;
}
.stat-card.active {
border-color: var(--accent-cyan);
box-shadow: 0 0 20px var(--accent-cyan-dim);
}
.stat-card.active::before {
opacity: 1;
}
.stat-card.active .stat-glow {
opacity: 0.5;
}
.stat-glow {
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.stat-card:hover .stat-glow {
opacity: 0.5;
}
/* Stat variations */
.stat-total {
--stat-color: #818cf8;
}
.stat-total::before {
background: linear-gradient(90deg, #6366f1, #818cf8);
}
.stat-total .stat-glow {
background: radial-gradient(circle at 30% 30%, rgba(99, 102, 241, 0.3), transparent 50%);
}
.stat-completed {
--stat-color: var(--success);
}
.stat-completed::before {
background: linear-gradient(90deg, #16a34a, var(--success));
}
.stat-completed .stat-glow {
background: radial-gradient(circle at 30% 30%, rgba(34, 197, 94, 0.3), transparent 50%);
}
.stat-processing {
--stat-color: var(--warning);
}
.stat-processing::before {
background: linear-gradient(90deg, #d97706, var(--warning));
}
.stat-processing .stat-glow {
background: radial-gradient(circle at 30% 30%, rgba(245, 158, 11, 0.3), transparent 50%);
}
.stat-failed {
--stat-color: var(--danger);
}
.stat-failed::before {
background: linear-gradient(90deg, #dc2626, var(--danger));
}
.stat-failed .stat-glow {
background: radial-gradient(circle at 30% 30%, rgba(239, 68, 68, 0.3), transparent 50%);
}
.stat-inner {
position: relative;
display: flex;
align-items: center;
gap: 16px;
padding: 20px 22px;
z-index: 1;
}
.stat-icon-wrap {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.05);
border-radius: var(--radius-md);
color: var(--stat-color);
}
.stat-info {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: #ffffff;
font-family: 'SF Mono', 'JetBrains Mono', monospace;
line-height: 1.1;
}
.stat-label {
font-size: 13px;
color: var(--text-muted);
margin-top: 2px;
}
/* ========================
File Container
======================== */
.file-container {
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
overflow: hidden;
overflow-x: auto;
}
/* ========================
Empty State
======================== */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 40px;
min-height: 450px;
}
.empty-illustration {
position: relative;
width: 140px;
height: 140px;
margin-bottom: 28px;
}
.orbit {
position: absolute;
border-radius: 50%;
border: 1px dashed rgba(0, 212, 255, 0.2);
}
.orbit-1 {
inset: 10px;
animation: orbit-rotate 20s linear infinite;
}
.orbit-2 {
inset: 30px;
border-color: rgba(0, 212, 255, 0.15);
animation: orbit-rotate 15s linear infinite reverse;
}
.orbit-3 {
inset: 50px;
border-color: rgba(0, 212, 255, 0.1);
animation: orbit-rotate 10s linear infinite;
}
@keyframes orbit-rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.empty-core {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 70px;
height: 70px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--bg-hover) 0%, var(--bg-card) 100%);
border: 1px solid var(--border-subtle);
border-radius: 50%;
color: var(--accent-cyan);
box-shadow: 0 0 40px var(--accent-cyan-dim);
}
.empty-title {
font-size: 22px;
font-weight: 600;
color: #ffffff;
margin: 0 0 8px;
}
.empty-desc {
font-size: 14px;
color: var(--text-muted);
margin: 0;
text-align: center;
}
/* ========================
Files Table
======================== */
.files-table-wrapper {
display: flex;
flex-direction: column;
min-width: 800px;
}
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 20px;
background: var(--bg-elevated);
border-bottom: 1px solid var(--border-subtle);
}
.table-select .select-all {
--el-checkbox-text-color: var(--text-secondary);
}
.selected-text {
color: var(--accent-cyan);
font-weight: 500;
}
.batch-delete-btn {
background: transparent !important;
border-color: var(--danger) !important;
color: var(--danger) !important;
}
.batch-delete-btn:hover {
background: var(--danger) !important;
color: white !important;
}
.files-table {
display: flex;
flex-direction: column;
}
.file-row {
display: grid;
grid-template-columns: 40px 50px 1fr 80px 100px 80px 70px;
align-items: center;
gap: 16px;
padding: 14px 20px;
border-bottom: 1px solid var(--border-subtle);
transition: background 0.15s ease;
}
.file-row.row-animated {
animation: row-in 0.3s ease backwards;
animation-delay: var(--delay);
}
@keyframes row-in {
from {
opacity: 0;
transform: translateX(-10px);
}
}
.file-row:hover {
background: var(--bg-hover);
}
.file-row.is-selected {
background: rgba(0, 212, 255, 0.05);
}
.file-row.is-processing {
background: rgba(245, 158, 11, 0.03);
}
.file-row.is-processing:hover {
background: rgba(245, 158, 11, 0.08);
}
/* Column styles */
.col-select {
display: flex;
justify-content: center;
}
.col-icon {
display: flex;
justify-content: center;
}
.file-type-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--type-color) 15%, transparent);
border-radius: var(--radius-sm);
color: var(--type-color);
}
.col-name {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.filename-text {
font-size: 14px;
font-weight: 500;
color: #ffffff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-ext {
font-size: 11px;
font-family: 'SF Mono', monospace;
color: var(--text-muted);
background: rgba(255, 255, 255, 0.05);
padding: 2px 6px;
border-radius: 4px;
flex-shrink: 0;
}
.col-size,
.col-date {
font-size: 13px;
color: var(--text-muted);
font-family: 'SF Mono', monospace;
min-width: 70px;
}
.col-status {
min-width: 80px;
}
/* Status Pill */
.status-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.status-processing {
background: rgba(245, 158, 11, 0.1);
color: var(--warning);
}
.status-processing .status-dot {
background: var(--warning);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.status-completed {
background: rgba(34, 197, 94, 0.1);
color: var(--success);
}
.status-completed .status-dot {
background: var(--success);
}
.status-failed {
background: rgba(239, 68, 68, 0.1);
color: var(--danger);
}
.status-failed .status-dot {
background: var(--danger);
}
.status-pending {
background: rgba(107, 114, 128, 0.1);
color: var(--text-muted);
}
.status-pending .status-dot {
background: var(--text-muted);
}
/* Actions */
.col-actions {
display: flex;
justify-content: flex-end;
gap: 4px;
min-width: 70px;
}
.action-btn {
color: var(--text-muted) !important;
transition: all 0.15s ease;
}
.action-btn:hover {
color: var(--accent-cyan) !important;
background: var(--accent-cyan-dim) !important;
}
.action-btn.delete:hover {
color: var(--danger) !important;
background: rgba(239, 68, 68, 0.1) !important;
}
/* ========================
Upload Dialog
======================== */
:deep(.upload-dialog .el-dialog) {
background: var(--bg-elevated) !important;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
}
:deep(.upload-dialog .el-dialog__header) {
padding: 20px 24px;
border-bottom: 1px solid var(--border-subtle);
}
:deep(.upload-dialog .el-dialog__title) {
font-weight: 600;
font-size: 18px;
color: #ffffff;
}
:deep(.upload-dialog .el-dialog__body) {
padding: 24px;
}
.upload-area {
border: 2px dashed var(--border-subtle);
border-radius: var(--radius-lg);
padding: 48px 24px;
cursor: pointer;
transition: all 0.2s ease;
background: var(--bg-card);
}
.upload-area:hover {
border-color: var(--accent-cyan);
background: var(--accent-cyan-dim);
}
.upload-content {
text-align: center;
}
.upload-illustration {
position: relative;
width: 80px;
height: 80px;
margin: 0 auto 20px;
}
.upload-ring {
position: absolute;
inset: 0;
border: 2px dashed var(--accent-cyan);
border-radius: 50%;
opacity: 0.3;
animation: ring-pulse 2s ease-in-out infinite;
}
@keyframes ring-pulse {
0%, 100% { transform: scale(1); opacity: 0.3; }
50% { transform: scale(1.1); opacity: 0.15; }
}
.upload-core {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent-cyan-dim);
border-radius: 50%;
color: var(--accent-cyan);
}
.upload-text {
font-size: 15px;
color: var(--text-secondary);
margin-bottom: 8px;
}
.upload-text em {
color: var(--accent-cyan);
font-style: normal;
font-weight: 600;
}
.upload-hint {
font-size: 13px;
color: var(--text-muted);
}
/* Selected Files */
.selected-area {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--border-subtle);
}
.selected-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
font-size: 14px;
color: var(--text-secondary);
}
.selected-header strong {
color: var(--accent-cyan);
}
.selected-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
max-height: 150px;
overflow-y: auto;
}
.selected-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--text-secondary);
}
/* ========================
Preview Modal
======================== */
.preview-backdrop {
position: fixed;
inset: 0;
z-index: 2000;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
}
.preview-modal {
position: fixed;
top: 20px;
bottom: 20px;
right: 40px;
z-index: 2001;
width: 720px;
max-width: calc(100vw - 80px);
background: var(--bg-elevated);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
overflow: hidden;
}
.preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-card);
}
.header-title {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
flex: 1;
}
.title-icon {
font-size: 20px;
color: var(--accent-cyan);
flex-shrink: 0;
}
.filename {
font-size: 15px;
font-weight: 600;
color: #ffffff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.close-btn {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: var(--radius-sm);
color: var(--text-muted) !important;
}
.close-btn:hover {
background: var(--bg-hover) !important;
color: #ffffff !important;
}
.preview-tabs-wrapper {
padding: 12px 20px 0;
background: var(--bg-card);
}
.preview-tabs {
position: relative;
display: flex;
background: var(--bg-elevated);
border-radius: var(--radius-md) var(--radius-md) 0 0;
padding: 4px;
border: 1px solid var(--border-subtle);
border-bottom: none;
}
.tab-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 10px 16px;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
position: relative;
z-index: 1;
}
.tab-item:hover:not(.active) {
color: var(--text-secondary);
}
.tab-item.active {
color: #ffffff;
}
.tab-indicator {
position: absolute;
top: 4px;
left: 4px;
width: calc(50% - 4px);
height: calc(100% - 8px);
background: var(--accent-cyan);
border-radius: var(--radius-sm);
transition: transform 0.2s ease;
z-index: 0;
opacity: 0.15;
}
.tab-indicator.at-right {
transform: translateX(100%);
}
.preview-content {
flex: 1;
overflow: auto;
background: var(--bg-primary);
border-left: 1px solid var(--border-subtle);
border-right: 1px solid var(--border-subtle);
}
.code-content {
font-family: 'SF Mono', 'JetBrains Mono', monospace;
font-size: 13px;
line-height: 1.7;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
margin: 0;
padding: 20px;
}
.pdf-viewer {
width: 100%;
height: 100%;
min-height: 400px;
border: none;
background: white;
}
.preview-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 300px;
color: var(--text-muted);
gap: 12px;
}
/* Transitions */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-right-enter-active {
transition: all 0.25s ease;
}
.slide-right-leave-active {
transition: all 0.15s ease;
}
.slide-right-enter-from {
opacity: 0;
transform: translateX(30px);
}
.slide-right-leave-to {
opacity: 0;
transform: translateX(20px);
}
/* ========================
Responsive
======================== */
@media (max-width: 900px) {
.file-manage {
padding: 20px;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.page-header {
flex-direction: column;
gap: 16px;
}
.header-actions {
width: 100%;
}
.header-actions .el-button {
flex: 1;
}
.file-row {
grid-template-columns: 40px 40px 1fr 60px;
gap: 8px;
}
.col-date,
.col-status {
display: none;
}
.col-actions {
min-width: 50px;
}
.preview-modal {
right: 20px;
left: 20px;
top: 20px;
bottom: 20px;
width: auto;
max-width: none;
}
}
</style>