378 lines
11 KiB
TypeScript
378 lines
11 KiB
TypeScript
|
|
import { defineComponent } from 'vue'
|
|||
|
|
import { ref, computed, onMounted } from 'vue'
|
|||
|
|
import { useRoute } from 'vue-router'
|
|||
|
|
import { ElMessage } from 'element-plus'
|
|||
|
|
import { fileApi } from '@/core/api'
|
|||
|
|
import DeleteDialog from '@/shared/components/common/DeleteDialog.vue'
|
|||
|
|
|
|||
|
|
export default defineComponent({
|
|||
|
|
name: 'FileManage',
|
|||
|
|
components: { DeleteDialog },
|
|||
|
|
setup() {
|
|||
|
|
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([])
|
|||
|
|
const deleteDialogVisible = ref(false)
|
|||
|
|
const pendingDeleteFile = ref(null)
|
|||
|
|
const deletingFile = ref(false)
|
|||
|
|
|
|||
|
|
// 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 clearSelection = () => {
|
|||
|
|
selectedFiles.value = []
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const batchDeleteDialogVisible = ref(false)
|
|||
|
|
const batchDeleting = ref(false)
|
|||
|
|
const batchDeleteFiles = ref([])
|
|||
|
|
|
|||
|
|
const batchDelete = async () => {
|
|||
|
|
if (selectedFiles.value.length === 0) return
|
|||
|
|
batchDeleteFiles.value = files.value.filter(f => selectedFiles.value.includes(f.id))
|
|||
|
|
batchDeleteDialogVisible.value = true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const executeBatchDelete = async () => {
|
|||
|
|
if (selectedFiles.value.length === 0) return
|
|||
|
|
batchDeleting.value = true
|
|||
|
|
try {
|
|||
|
|
for (const fileId of selectedFiles.value) {
|
|||
|
|
await fileApi.delete(projectId.value, fileId)
|
|||
|
|
}
|
|||
|
|
ElMessage.success(`已删除 ${selectedFiles.value.length} 个文件`)
|
|||
|
|
selectedFiles.value = []
|
|||
|
|
batchDeleteDialogVisible.value = false
|
|||
|
|
fetchFiles()
|
|||
|
|
} catch (error) {
|
|||
|
|
ElMessage.error('删除失败')
|
|||
|
|
} finally {
|
|||
|
|
batchDeleting.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 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 previewError = 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 {
|
|||
|
|
deletingFile.value = true
|
|||
|
|
await fileApi.delete(projectId.value, file.id)
|
|||
|
|
ElMessage.success('删除成功')
|
|||
|
|
fetchFiles()
|
|||
|
|
} catch (error) {
|
|||
|
|
ElMessage.error('删除失败')
|
|||
|
|
} finally {
|
|||
|
|
deletingFile.value = false
|
|||
|
|
deleteDialogVisible.value = false
|
|||
|
|
pendingDeleteFile.value = null
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const openDeleteDialog = (file) => {
|
|||
|
|
pendingDeleteFile.value = file
|
|||
|
|
deleteDialogVisible.value = true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const confirmDeleteFile = async () => {
|
|||
|
|
if (!pendingDeleteFile.value) return
|
|||
|
|
await handleDelete(pendingDeleteFile.value)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handlePreview = async (file) => {
|
|||
|
|
previewFile.value = file
|
|||
|
|
previewVisible.value = true
|
|||
|
|
previewContent.value = ''
|
|||
|
|
previewError.value = ''
|
|||
|
|
previewLoading.value = true
|
|||
|
|
previewMode.value = 'source'
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await loadPreviewContent()
|
|||
|
|
} finally {
|
|||
|
|
previewLoading.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const loadPreviewContent = async () => {
|
|||
|
|
if (!previewFile.value) return
|
|||
|
|
previewLoading.value = true
|
|||
|
|
previewContent.value = ''
|
|||
|
|
previewError.value = ''
|
|||
|
|
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 if (response.status === 404) {
|
|||
|
|
previewError.value = previewMode.value === 'source'
|
|||
|
|
? '源文件不存在或已被删除'
|
|||
|
|
: 'Markdown 内容不存在,请等待处理完成'
|
|||
|
|
} else if (response.status === 500) {
|
|||
|
|
previewError.value = '服务器内部错误,请稍后重试'
|
|||
|
|
} else {
|
|||
|
|
previewError.value = `加载失败 (${response.status})`
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
previewError.value = '网络错误,请检查网络连接'
|
|||
|
|
} 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())
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
route,
|
|||
|
|
projectId,
|
|||
|
|
loading,
|
|||
|
|
files,
|
|||
|
|
filterStatus,
|
|||
|
|
isInitialLoad,
|
|||
|
|
filteredFiles,
|
|||
|
|
uploadDialogVisible,
|
|||
|
|
uploading,
|
|||
|
|
uploadRef,
|
|||
|
|
fileList,
|
|||
|
|
deleteDialogVisible,
|
|||
|
|
pendingDeleteFile,
|
|||
|
|
deletingFile,
|
|||
|
|
selectedFiles,
|
|||
|
|
isAllSelected,
|
|||
|
|
selectedCount,
|
|||
|
|
toggleSelectAll,
|
|||
|
|
toggleSelect,
|
|||
|
|
isSelected,
|
|||
|
|
clearSelection,
|
|||
|
|
batchDeleteDialogVisible,
|
|||
|
|
batchDeleting,
|
|||
|
|
batchDeleteFiles,
|
|||
|
|
batchDelete,
|
|||
|
|
executeBatchDelete,
|
|||
|
|
previewVisible,
|
|||
|
|
previewFile,
|
|||
|
|
previewContent,
|
|||
|
|
previewLoading,
|
|||
|
|
previewMode,
|
|||
|
|
isPdfPreview,
|
|||
|
|
pdfDataUrl,
|
|||
|
|
previewError,
|
|||
|
|
completedFiles,
|
|||
|
|
processingFiles,
|
|||
|
|
failedFiles,
|
|||
|
|
fetchFiles,
|
|||
|
|
handleUpload,
|
|||
|
|
handleChange,
|
|||
|
|
handleRemove,
|
|||
|
|
triggerUpload,
|
|||
|
|
submitUpload,
|
|||
|
|
handleDelete,
|
|||
|
|
openDeleteDialog,
|
|||
|
|
confirmDeleteFile,
|
|||
|
|
handlePreview,
|
|||
|
|
loadPreviewContent,
|
|||
|
|
switchPreviewMode,
|
|||
|
|
getFileIcon,
|
|||
|
|
getTypeColor,
|
|||
|
|
getFileExt,
|
|||
|
|
getStatusText,
|
|||
|
|
formatSize,
|
|||
|
|
formatDate
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
})
|