2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-04 03:07:20 +08:00

feat: 文件上传

This commit is contained in:
2026-01-14 19:48:33 +08:00
parent ec9a3117f7
commit 50e073c25e
3 changed files with 274 additions and 7 deletions

View File

@@ -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>

View File

@@ -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"

View File

@@ -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" />