Files
YG-Datasets/frontend/src/views/project/FileManage.vue

613 lines
15 KiB
Vue
Raw Normal View History

2026-03-17 14:36:31 +08:00
<template>
<div class="file-manage">
<!-- Page Header -->
<div class="page-header">
<div class="header-left">
<h2>文件管理</h2>
<p class="header-desc">上传和管理您的文档</p>
</div>
<el-button type="primary" @click="handleUpload" class="upload-btn">
<el-icon><Upload /></el-icon>
上传文件
</el-button>
</div>
<!-- Stats Cards -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon cyan">
<el-icon><Document /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ files.length }}</span>
<span class="stat-label">总文件数</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon violet">
<el-icon><Check /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ completedFiles }}</span>
<span class="stat-label">已处理</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon orange">
<el-icon><Loading /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ processingFiles.length }}</span>
<span class="stat-label">处理中</span>
</div>
</div>
</div>
<!-- Tabs -->
<el-tabs v-model="activeTab" class="file-tabs">
<el-tab-pane label="全部文件" name="all">
<div class="file-list" v-loading="loading">
<div v-if="!loading && files.length === 0" class="empty-state">
<div class="empty-illustration">
<div class="circle-ring"></div>
<el-icon size="56"><UploadFilled /></el-icon>
</div>
<h3>暂无文件</h3>
<p>上传您的第一个文档开始处理</p>
<el-button type="primary" @click="handleUpload">上传文件</el-button>
</div>
<div v-else class="files-grid">
<div
v-for="(file, index) in files"
:key="file.id"
class="file-card"
:style="{ '--delay': index * 0.05 + 's' }"
>
<div class="file-icon" :style="{ background: getFileBg(file.file_type) }">
<el-icon size="24" color="white">
<component :is="getFileIcon(file.file_type)" />
</el-icon>
</div>
<div class="file-info">
<h4 class="file-name">{{ file.filename }}</h4>
<div class="file-meta">
<span class="file-size">{{ formatSize(file.size) }}</span>
<span class="file-divider"></span>
<span class="file-date">{{ formatDate(file.created_at) }}</span>
</div>
<div class="file-progress" v-if="file.status === 'processing'">
<el-progress :percentage="50" :indeterminate="true" :show-text="false" />
<span class="progress-text">处理中...</span>
</div>
</div>
<div class="file-actions">
<el-tag v-if="file.status === 'completed'" type="success" size="small" effect="dark">
就绪
</el-tag>
<el-tag v-else-if="file.status === 'processing'" size="small" effect="dark">
处理中
</el-tag>
<el-button
v-if="file.status === 'completed'"
type="primary"
size="small"
@click="goToSplit(file)"
class="action-btn"
>
分割
</el-button>
<el-popconfirm title="确定删除此文件?" @confirm="handleDelete(file)">
<template #reference>
<el-button type="danger" size="small" plain class="action-btn">
<el-icon><Delete /></el-icon>
</el-button>
</template>
</el-popconfirm>
</div>
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="处理中" name="processing">
<div class="file-list" v-loading="loading">
<div v-if="processingFiles.length === 0" class="empty-state small">
<el-icon size="40"><Clock /></el-icon>
<p>暂无正在处理的文件</p>
</div>
<div v-else class="files-grid">
<div v-for="file in processingFiles" :key="file.id" class="file-card processing">
<div class="file-icon processing">
<el-icon size="24" color="white"><Loading /></el-icon>
</div>
<div class="file-info">
<h4 class="file-name">{{ file.filename }}</h4>
<div class="file-progress">
<el-progress :percentage="50" :indeterminate="true" :show-text="false" />
<span class="progress-text">处理中...</span>
</div>
</div>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
<!-- Upload Dialog -->
<el-dialog v-model="uploadDialogVisible" title="上传文件" width="520px" class="upload-dialog">
<div class="upload-area">
<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"
>
<div class="upload-content">
<div class="upload-icon">
<el-icon size="48"><UploadFilled /></el-icon>
</div>
<div class="upload-text">
拖拽文件到此处或 <em>点击上传</em>
</div>
<div class="upload-formats">
<span>PDF</span>
<span>DOCX</span>
<span>EPUB</span>
<span>Excel</span>
<span>CSV</span>
<span>Markdown</span>
</div>
</div>
</el-upload>
</div>
<template #footer>
<el-button @click="uploadDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitUpload" :loading="uploading">
上传 {{ fileList.length > 0 ? `(${fileList.length})` : '' }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { fileApi } from '@/api'
const route = useRoute()
const router = useRouter()
const projectId = computed(() => route.params.id)
const loading = ref(false)
const files = ref([])
const activeTab = ref('all')
const uploadDialogVisible = ref(false)
const uploading = ref(false)
const uploadRef = ref(null)
const fileList = 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 fetchFiles = async () => {
loading.value = true
try {
const res = await fileApi.list(projectId.value)
// API returns array directly via interceptor
files.value = res || []
2026-03-17 14:36:31 +08:00
} catch (error) {
files.value = []
} finally {
loading.value = false
}
}
const handleUpload = () => {
fileList.value = []
uploadDialogVisible.value = true
}
const handleChange = (file, files) => { fileList.value = files }
const handleRemove = (file, files) => { fileList.value = files }
const submitUpload = async () => {
if (fileList.value.length === 0) {
ElMessage.warning('请先选择文件')
return
}
uploading.value = true
try {
for (const item of fileList.value) {
const formData = new FormData()
formData.append('file', item.raw)
await fileApi.upload(projectId.value, formData)
}
ElMessage.success('上传完成')
uploadDialogVisible.value = false
fetchFiles()
} catch (error) {
ElMessage.error('上传失败')
} finally {
uploading.value = false
}
}
const handleDelete = async (file) => {
try {
await fileApi.delete(projectId.value, file.id)
ElMessage.success('删除成功')
fetchFiles()
} catch (error) {
ElMessage.error('删除失败')
}
}
const goToSplit = (file) => router.push(`/project/${projectId.value}/split?fileId=${file.id}`)
const getFileIcon = (type) => {
const map = { pdf: 'Document', docx: 'Document', xlsx: 'Grid', csv: 'Document', epub: 'Notebook', md: 'Document', txt: 'Document' }
return map[type] || 'Document'
}
const getFileBg = (type) => {
const map = { pdf: '#ef4444', docx: '#3b82f6', xlsx: '#22c55e', csv: '#22c55e', epub: '#f59e0b', md: '#8b5cf6', txt: '#6b7280' }
return map[type] || '#6b7280'
}
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' })
}
onMounted(() => fetchFiles())
</script>
<style scoped>
.file-manage {
padding: 32px;
max-width: 1200px;
}
/* Header */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 28px;
}
.header-left h2 {
font-size: 24px;
font-weight: 600;
margin-bottom: 4px;
}
.header-desc {
font-size: 14px;
color: var(--text-tertiary);
}
.upload-btn {
padding: 10px 20px;
font-weight: 500;
border-radius: var(--radius-md);
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 28px;
}
.stat-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: var(--bg-secondary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
}
.stat-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
font-size: 22px;
}
.stat-icon.cyan { background: var(--accent-primary-muted); color: var(--accent-primary); }
.stat-icon.violet { background: rgba(124, 58, 237, 0.15); color: var(--accent-secondary); }
.stat-icon.orange { background: rgba(251, 191, 36, 0.15); color: var(--warning); }
.stat-info {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 24px;
font-weight: 700;
}
.stat-label {
font-size: 13px;
color: var(--text-tertiary);
}
/* Tabs */
.file-tabs {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
padding: 20px;
border: 1px solid var(--border-subtle);
}
:deep(.el-tabs__header) {
margin-bottom: 20px;
}
:deep(.el-tabs__nav-wrap::after) {
background: var(--border-subtle);
}
/* Files Grid */
.files-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.file-card {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 20px;
background: var(--bg-tertiary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
transition: all var(--transition-fast);
animation: cardSlide 0.4s ease backwards;
animation-delay: var(--delay);
}
@keyframes cardSlide {
from {
opacity: 0;
transform: translateX(-10px);
}
}
.file-card:hover {
border-color: var(--border-default);
}
.file-card.processing {
opacity: 0.8;
}
.file-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
flex-shrink: 0;
}
.file-icon.processing {
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.file-info {
flex: 1;
min-width: 0;
}
.file-name {
font-size: 14px;
font-weight: 600;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-muted);
}
.file-divider {
opacity: 0.5;
}
.file-progress {
margin-top: 8px;
}
.file-progress .el-progress {
width: 150px;
}
.progress-text {
font-size: 11px;
color: var(--text-muted);
margin-top: 4px;
display: block;
}
.file-actions {
display: flex;
align-items: center;
gap: 8px;
}
.action-btn {
padding: 6px 12px;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 60px 20px;
}
.empty-state.small {
padding: 40px 20px;
}
.empty-illustration {
position: relative;
width: 120px;
height: 120px;
margin: 0 auto 24px;
display: flex;
align-items: center;
justify-content: center;
}
.circle-ring {
position: absolute;
inset: 0;
border: 2px dashed var(--border-default);
border-radius: 50%;
animation: spin 20s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.empty-illustration .el-icon {
color: var(--text-muted);
}
.empty-state h3 {
font-size: 18px;
margin-bottom: 8px;
}
.empty-state p {
color: var(--text-tertiary);
font-size: 14px;
margin-bottom: 20px;
}
/* Upload Dialog */
:deep(.upload-dialog .el-dialog__header) {
padding: 20px 24px;
border-bottom: 1px solid var(--border-subtle);
}
:deep(.upload-dialog .el-dialog__body) {
padding: 24px;
}
.upload-area {
border: 2px dashed var(--border-default);
border-radius: var(--radius-lg);
padding: 40px;
transition: all var(--transition-fast);
}
.upload-area:hover {
border-color: var(--accent-primary);
background: var(--accent-primary-muted);
}
.upload-component :deep(.el-upload-dragger) {
background: transparent;
border: none;
padding: 20px;
}
.upload-content {
text-align: center;
}
.upload-icon {
color: var(--accent-primary);
margin-bottom: 16px;
}
.upload-text {
font-size: 16px;
margin-bottom: 12px;
}
.upload-text em {
color: var(--accent-primary);
font-style: normal;
font-weight: 500;
}
.upload-formats {
display: flex;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
}
.upload-formats span {
padding: 4px 10px;
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
font-size: 11px;
color: var(--text-muted);
}
/* Responsive */
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: 1fr;
}
.file-card {
flex-wrap: wrap;
}
.file-actions {
width: 100%;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border-subtle);
}
}
</style>