refactor: 前端架构重构 - 提取 CSS 和逻辑到独立模块
前端重构: - 删除旧的大体积 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>
This commit is contained in:
377
frontend/src/page-logic/ProjectFilePage.ts
Normal file
377
frontend/src/page-logic/ProjectFilePage.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user