前端重构: - 删除旧的大体积 Vue 组件(HomeView, FileManage, TextSplit 等) - 删除旧的 composables(useFormatters, useModels, useProjects) - 新增 core/, page-logic/, pages/, shared/ 模块化目录结构 - 提取 CSS 到 styles/pages/ 目录 - 添加全局样式 variables.css 和 common.css 后端 API 更新: - chunks: 语义分割 API 增强 - files: 文件处理 API 更新 - models: 模型管理 API 更新 - questions: 问答管理 API 更新 - database: 数据库连接优化 - semantic_embedding: 语义嵌入服务优化 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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
|
||
}
|
||
}
|
||
})
|