From 50e073c25efed65be5e6a8bb6d9a3108e290d992 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=97=E5=AD=90?= Date: Wed, 14 Jan 2026 19:48:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=87=E4=BB=B6=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/views/file/IndexView.vue | 175 ++++++++++++++++++++++++++++- web/src/views/file/ToolBar.vue | 4 +- web/src/views/file/UploadModal.vue | 102 ++++++++++++++++- 3 files changed, 274 insertions(+), 7 deletions(-) diff --git a/web/src/views/file/IndexView.vue b/web/src/views/file/IndexView.vue index fb34c320..641612ed 100644 --- a/web/src/views/file/IndexView.vue +++ b/web/src/views/file/IndexView.vue @@ -9,6 +9,7 @@ import ListView from '@/views/file/ListView.vue' import PathInput from '@/views/file/PathInput.vue' import PermissionModal from '@/views/file/PermissionModal.vue' import ToolBar from '@/views/file/ToolBar.vue' +import UploadModal from '@/views/file/UploadModal.vue' import type { FileInfo, Marked } from '@/views/file/types' const fileStore = useFileStore() @@ -21,10 +22,144 @@ const permissionFileInfoList = ref([]) const compress = ref(false) const permission = ref(false) + +// 上传相关 +const upload = ref(false) +const droppedFiles = ref([]) +const isDragging = ref(false) + +// 处理拖拽进入 +const handleDragEnter = (e: DragEvent) => { + e.preventDefault() + e.stopPropagation() + // 检查是否有文件 + if (e.dataTransfer?.types.includes('Files')) { + isDragging.value = true + } +} + +// 处理拖拽离开 +const handleDragLeave = (e: DragEvent) => { + e.preventDefault() + e.stopPropagation() + // 只有当离开整个容器时才隐藏 + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() + if ( + e.clientX <= rect.left || + e.clientX >= rect.right || + e.clientY <= rect.top || + e.clientY >= rect.bottom + ) { + isDragging.value = false + } +} + +// 处理拖拽悬停 +const handleDragOver = (e: DragEvent) => { + e.preventDefault() + e.stopPropagation() +} + +// 递归读取目录中的所有文件 +const readDirectoryRecursively = async ( + entry: FileSystemDirectoryEntry, + basePath: string = '' +): Promise => { + const files: File[] = [] + const reader = entry.createReader() + + const readEntries = (): Promise => { + return new Promise((resolve, reject) => { + reader.readEntries(resolve, reject) + }) + } + + let entries: FileSystemEntry[] = [] + // readEntries 可能需要多次调用才能获取所有条目 + let batch: FileSystemEntry[] + do { + batch = await readEntries() + entries = entries.concat(batch) + } while (batch.length > 0) + + for (const childEntry of entries) { + const childPath = basePath ? `${basePath}/${childEntry.name}` : childEntry.name + if (childEntry.isFile) { + const fileEntry = childEntry as FileSystemFileEntry + const file = await new Promise((resolve, reject) => { + fileEntry.file((f) => { + // 创建带有相对路径的新 File 对象 + const newFile = new File([f], childPath, { type: f.type, lastModified: f.lastModified }) + resolve(newFile) + }, reject) + }) + files.push(file) + } else if (childEntry.isDirectory) { + const subFiles = await readDirectoryRecursively( + childEntry as FileSystemDirectoryEntry, + childPath + ) + files.push(...subFiles) + } + } + + return files +} + +// 处理拖拽放下 +const handleDrop = async (e: DragEvent) => { + e.preventDefault() + e.stopPropagation() + isDragging.value = false + + const items = e.dataTransfer?.items + if (!items || items.length === 0) return + + const files: File[] = [] + + // 使用 webkitGetAsEntry 来支持文件夹 + for (let i = 0; i < items.length; i++) { + const item = items[i] + if (item.kind === 'file') { + const entry = item.webkitGetAsEntry() + if (entry) { + if (entry.isFile) { + const file = item.getAsFile() + if (file) files.push(file) + } else if (entry.isDirectory) { + const dirFiles = await readDirectoryRecursively( + entry as FileSystemDirectoryEntry, + entry.name + ) + files.push(...dirFiles) + } + } + } + } + + if (files.length > 0) { + droppedFiles.value = files + upload.value = true + } +} + +// 监听上传弹窗关闭,清空预拖入的文件 +watch(upload, (val) => { + if (!val) { + droppedFiles.value = [] + } +}) + + diff --git a/web/src/views/file/ToolBar.vue b/web/src/views/file/ToolBar.vue index 99a35220..37ba905d 100644 --- a/web/src/views/file/ToolBar.vue +++ b/web/src/views/file/ToolBar.vue @@ -3,7 +3,6 @@ import file from '@/api/panel/file' import PtyTerminalModal from '@/components/common/PtyTerminalModal.vue' import { useFileStore } from '@/store' import { checkName, lastDirectory } from '@/utils/file' -import UploadModal from '@/views/file/UploadModal.vue' import type { Marked } from '@/views/file/types' import { useGettext } from 'vue3-gettext' @@ -16,11 +15,11 @@ const marked = defineModel('marked', { type: Array, default: () => [] const markedType = defineModel('markedType', { type: String, required: true }) const compress = defineModel('compress', { type: Boolean, required: true }) const permission = defineModel('permission', { type: Boolean, required: true }) +const upload = defineModel('upload', { type: Boolean, required: true }) // 终端弹窗 const terminalModal = ref(false) -const upload = ref(false) const download = ref(false) const downloadModel = ref({ path: '', @@ -300,7 +299,6 @@ const handleSortSelect = (key: string) => { {{ $gettext('Submit') }} - -import type { UploadCustomRequestOptions } from 'naive-ui' +import type { UploadCustomRequestOptions, UploadFileInfo, UploadInst } from 'naive-ui' import { useGettext } from 'vue3-gettext' import api from '@/api/panel/file' @@ -7,7 +7,71 @@ import api from '@/api/panel/file' const { $gettext } = useGettext() const show = defineModel('show', { type: Boolean, required: true }) const path = defineModel('path', { type: String, required: true }) -const upload = ref(null) + +const props = defineProps<{ + initialFiles?: File[] +}>() + +const upload = ref(null) +const fileList = ref([]) + +// 文件数量阈值,超过此数量需要二次确认 +const FILE_COUNT_THRESHOLD = 100 + +// 监听预拖入的文件 +watch( + () => props.initialFiles, + async (files) => { + if (files && files.length > 0) { + // 如果文件数量超过阈值,弹窗确认 + if (files.length > FILE_COUNT_THRESHOLD) { + window.$dialog.warning({ + title: $gettext('Confirm Upload'), + content: $gettext( + 'You are about to upload %{count} files. This may take a while. Do you want to continue?', + { count: files.length } + ), + positiveText: $gettext('Continue'), + negativeText: $gettext('Cancel'), + onPositiveClick: () => { + addFilesToList(files, true) + }, + onNegativeClick: () => { + show.value = false + } + }) + } else { + addFilesToList(files, true) + } + } + }, + { immediate: true } +) + +// 将文件添加到上传列表 +const addFilesToList = (files: File[], autoUpload: boolean = false) => { + const newFiles: UploadFileInfo[] = files.map((file, index) => ({ + id: `dropped-${Date.now()}-${index}`, + name: file.name, + status: 'pending' as const, + file: file + })) + fileList.value = newFiles + + // 自动开始上传 + if (autoUpload) { + nextTick(() => { + upload.value?.submit() + }) + } +} + +// 监听弹窗关闭,清空文件列表 +watch(show, (val) => { + if (!val) { + fileList.value = [] + } +}) const uploadRequest = ({ file, onFinish, onError, onProgress }: UploadCustomRequestOptions) => { const formData = new FormData() @@ -29,6 +93,31 @@ const uploadRequest = ({ file, onFinish, onError, onProgress }: UploadCustomRequ onProgress({ percent: Math.ceil((progress.loaded / progress.total) * 100) }) }) } + +// 处理文件选择变化(用于文件数量确认) +const handleChange = (data: { fileList: UploadFileInfo[] }) => { + const newFiles = data.fileList.filter( + (f) => !fileList.value.some((existing) => existing.id === f.id) + ) + + // 如果新增文件数量超过阈值,弹窗确认 + if (newFiles.length > FILE_COUNT_THRESHOLD) { + window.$dialog.warning({ + title: $gettext('Confirm Upload'), + content: $gettext( + 'You are about to upload %{count} files. This may take a while. Do you want to continue?', + { count: newFiles.length } + ), + positiveText: $gettext('Continue'), + negativeText: $gettext('Cancel'), + onPositiveClick: () => { + fileList.value = data.fileList + } + }) + } else { + fileList.value = data.fileList + } +}