mirror of
https://github.com/acepanel/panel.git
synced 2026-02-04 03:07:20 +08:00
feat: 文件上传
This commit is contained in:
@@ -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<FileInfo[]>([])
|
||||
|
||||
const compress = ref(false)
|
||||
const permission = ref(false)
|
||||
|
||||
// 上传相关
|
||||
const upload = ref(false)
|
||||
const droppedFiles = ref<File[]>([])
|
||||
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<File[]> => {
|
||||
const files: File[] = []
|
||||
const reader = entry.createReader()
|
||||
|
||||
const readEntries = (): Promise<FileSystemEntry[]> => {
|
||||
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<File>((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 = []
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<common-page show-footer flex>
|
||||
<common-page
|
||||
show-footer
|
||||
flex
|
||||
@dragenter="handleDragEnter"
|
||||
@dragleave="handleDragLeave"
|
||||
@dragover="handleDragOver"
|
||||
@drop="handleDrop"
|
||||
>
|
||||
<n-flex vertical :size="20" class="flex-1 min-h-0">
|
||||
<path-input
|
||||
v-model:path="fileStore.path"
|
||||
@@ -38,6 +173,7 @@ const permission = ref(false)
|
||||
v-model:markedType="markedType"
|
||||
v-model:compress="compress"
|
||||
v-model:permission="permission"
|
||||
v-model:upload="upload"
|
||||
/>
|
||||
<list-view
|
||||
v-model:path="fileStore.path"
|
||||
@@ -61,5 +197,42 @@ const permission = ref(false)
|
||||
v-model:file-info-list="permissionFileInfoList"
|
||||
/>
|
||||
</n-flex>
|
||||
|
||||
<!-- 拖拽上传遮罩 -->
|
||||
<div v-if="isDragging" class="drag-overlay">
|
||||
<div class="drag-content">
|
||||
<the-icon icon="mdi:cloud-upload" :size="64" />
|
||||
<p>释放文件以上传</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上传弹窗 -->
|
||||
<upload-modal
|
||||
v-model:show="upload"
|
||||
v-model:path="fileStore.path"
|
||||
:initial-files="droppedFiles"
|
||||
/>
|
||||
</common-page>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.drag-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.drag-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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[]>('marked', { type: Array, default: () => []
|
||||
const markedType = defineModel<string>('markedType', { type: String, required: true })
|
||||
const compress = defineModel<boolean>('compress', { type: Boolean, required: true })
|
||||
const permission = defineModel<boolean>('permission', { type: Boolean, required: true })
|
||||
const upload = defineModel<boolean>('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) => {
|
||||
<n-button type="info" block @click="handleDownload">{{ $gettext('Submit') }}</n-button>
|
||||
</n-space>
|
||||
</n-modal>
|
||||
<upload-modal v-model:show="upload" v-model:path="path" />
|
||||
<!-- 终端弹窗 -->
|
||||
<pty-terminal-modal
|
||||
v-model:show="terminalModal"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
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<boolean>('show', { type: Boolean, required: true })
|
||||
const path = defineModel<string>('path', { type: String, required: true })
|
||||
const upload = ref<any>(null)
|
||||
|
||||
const props = defineProps<{
|
||||
initialFiles?: File[]
|
||||
}>()
|
||||
|
||||
const upload = ref<UploadInst | null>(null)
|
||||
const fileList = ref<UploadFileInfo[]>([])
|
||||
|
||||
// 文件数量阈值,超过此数量需要二次确认
|
||||
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
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -42,7 +131,14 @@ const uploadRequest = ({ file, onFinish, onError, onProgress }: UploadCustomRequ
|
||||
:segmented="false"
|
||||
>
|
||||
<n-flex vertical>
|
||||
<n-upload ref="upload" multiple directory-dnd :custom-request="uploadRequest">
|
||||
<n-upload
|
||||
ref="upload"
|
||||
v-model:file-list="fileList"
|
||||
multiple
|
||||
directory-dnd
|
||||
:custom-request="uploadRequest"
|
||||
@change="handleChange"
|
||||
>
|
||||
<n-upload-dragger>
|
||||
<div style="margin-bottom: 12px">
|
||||
<the-icon :size="60" icon="mdi:arrow-up-bold-box-outline" />
|
||||
|
||||
Reference in New Issue
Block a user