mirror of
https://github.com/rustfs/console.git
synced 2026-09-19 01:47:44 +08:00
feat: 上传
This commit is contained in:
@@ -12,11 +12,12 @@
|
||||
<template #extra>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<object-upload-stats />
|
||||
<n-button>
|
||||
<Icon name="ri:add-line" class="mr-2" />
|
||||
<span>新建目录</span>
|
||||
</n-button>
|
||||
<n-button>
|
||||
<n-button @click="() => uploadPickerVisible = true">
|
||||
<Icon name="ri:file-add-line" class="mr-2" />
|
||||
<span>上传文件/文件夹</span>
|
||||
</n-button>
|
||||
@@ -38,6 +39,7 @@
|
||||
</template>
|
||||
</n-page-header>
|
||||
<n-data-table class="border dark:border-neutral-700 rounded overflow-hidden" :columns="columns" :data="objects" :pagination="false" :bordered="false" />
|
||||
<object-upload-picker :show="uploadPickerVisible" @update:show="val => (uploadPickerVisible = val)" :bucketName="bucketName" :prefix="prefix" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -48,12 +50,24 @@ import { NuxtLink } from '#components'
|
||||
import { ListObjectsV2Command, type _Object, type CommonPrefix } from '@aws-sdk/client-s3'
|
||||
import { joinRelativeURL } from 'ufo'
|
||||
import { computed, ref, type VNode } from 'vue'
|
||||
import { useUploadTaskManagerStore } from '~/store/upload-tasks'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps<{ bucket: string; path: string }>()
|
||||
|
||||
const uploadPickerVisible = ref(false)
|
||||
|
||||
// 上传任务
|
||||
const uploadTaskStore = useUploadTaskManagerStore()
|
||||
const tasks = computed(() => uploadTaskStore.tasks)
|
||||
|
||||
// 当任务变化时,刷新数据
|
||||
watch(() => tasks, () => {
|
||||
setTimeout(refresh, 500);
|
||||
}, { deep: true })
|
||||
|
||||
// bucketName
|
||||
const bucketName = computed(() => props.bucket as string)
|
||||
// 当前路径的前缀, example: '/folder1/folder2/'
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<template v-for="(segment) in displaySegments">
|
||||
<span class="text-gray-500">/</span>
|
||||
<button @click="() => handleOnClick(segment)" :class="{ 'text-blue-500 hover:underline': segment.index > -1 }">{{ segment.value }}</button>
|
||||
<button @click="() => handleOnClick(segment)" :class="{ 'text-blue-500 hover:underline': segment.index > -1, 'cursor-default': segment.index === -1 }">{{ segment.value
|
||||
}}</button>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
<template>
|
||||
<n-modal :show="show" @update:show="val => $emit('update:show', val)" size="huge">
|
||||
<template #header>
|
||||
<div style="display:flex; justify-content: space-between; align-items:center;">
|
||||
<span>对象预览: {{ objectKey }}</span>
|
||||
<n-button size="small" ghost @click="closeModal">关闭</n-button>
|
||||
</div>
|
||||
</template>
|
||||
<n-card class="max-w-screen-md">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-4 truncate">
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<n-modal :show="show" @update:show="val => $emit('update:show', val)" size="huge">
|
||||
<n-card class="max-w-screen-md">
|
||||
<template #header>
|
||||
<div style="display:flex; justify-content: space-between; align-items:center;">
|
||||
<span>上传文件</span>
|
||||
<n-button size="small" ghost @click="closeModal">关闭</n-button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<input type="file" ref="fileInput" multiple hidden @change="handleFileSelect" />
|
||||
<input type="file" ref="folderInput" webkitdirectory directory hidden @change="handleFolderSelect" />
|
||||
<n-button type="primary" @click="selectFile">选择文件</n-button>
|
||||
<div>或</div>
|
||||
<n-button @click="selectFolder">选择文件夹</n-button>
|
||||
<div>上传至</div>
|
||||
<div class="text-cyan-600">{{ bucketName }}{{ prefix || '/' }}</div>
|
||||
</div>
|
||||
|
||||
<n-alert title="" type="info">
|
||||
若上传路径中存在同名文件,上传将覆盖原有文件。
|
||||
</n-alert>
|
||||
|
||||
<div @dragover.prevent @drop.prevent="handleDrop">
|
||||
<div v-if="selectedItems.length === 0" class="flex flex-col gap-6 items-center justify-center border border-dashed border-muted rounded p-4 h-[40vh]">
|
||||
<div class="text-gray-500 font-bold">未选择文件/文件夹</div>
|
||||
<div class="text-muted-foreground text-center">
|
||||
Chrome 和 FireFox 支持拖拽到此区域上传,支持选择多个文件/文件夹<br>
|
||||
单个文件最大支持 512GB,上传更大的文件请使用命令行工具
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 展示文件与文件夹 -->
|
||||
<n-list v-if="selectedItems.length" bordered class="h-[40vh] overflow-y-auto sticky-header relative">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2 font-bold">
|
||||
<div class="flex-1">已选择文件/文件夹</div>
|
||||
<div class="w-32 text-center">大小</div>
|
||||
<div class="w-32 text-center">操作</div>
|
||||
</div>
|
||||
</template>
|
||||
<n-list-item v-for="(item, index) in selectedItems" :key="index">
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
<div class="flex-1">
|
||||
<span v-if="item.type === 'folder'">{{ item.name }}/</span>
|
||||
<span v-else>{{ item.name }}</span>
|
||||
</div>
|
||||
<div class="w-32 text-center">{{ formatBytes(item.size) }}</div>
|
||||
<div class="w-32 text-center">
|
||||
<n-button text type="info" @click="removeItem(index)">删除</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center gap-4">
|
||||
<n-button type="default" :disabled="!hasFiles">参数配置</n-button>
|
||||
<n-button type="primary" :disabled="!hasFiles" @click="handleUpload">开始上传</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineEmits, defineProps, ref } from 'vue'
|
||||
import { useUploadTaskManagerStore } from '~/store/upload-tasks'
|
||||
|
||||
const uploadTaskManagerStore = useUploadTaskManagerStore()
|
||||
|
||||
interface SelectedItem {
|
||||
type: 'file' | 'folder'
|
||||
name: string
|
||||
size: number
|
||||
files?: File[]
|
||||
}
|
||||
|
||||
const emit = defineEmits(['update:show'])
|
||||
|
||||
const props = defineProps<{ show: boolean; bucketName: string; prefix: string }>()
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const folderInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// 待上传的文件/文件夹列表
|
||||
const selectedItems = ref<SelectedItem[]>([])
|
||||
|
||||
const closeModal = () => emit('update:show', false)
|
||||
const selectFile = () => fileInput.value?.click()
|
||||
const selectFolder = () => folderInput.value?.click()
|
||||
|
||||
const handleFileSelect = (e: Event) => {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (input.files) {
|
||||
// 更新展示列表
|
||||
Array.from(input.files).forEach(f => selectedItems.value.push({ type: 'file', name: f.name, size: f.size, files: [f] }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleFolderSelect = (e: Event) => {
|
||||
const input = e.target as HTMLInputElement
|
||||
|
||||
if (input.files) {
|
||||
const folderFiles = Array.from(input.files)
|
||||
|
||||
// 找到每一个文件的 top-level 文件夹
|
||||
const folderMap = new Map<string, File[]>()
|
||||
for (const file of folderFiles) {
|
||||
const parts = file.webkitRelativePath.split('/')
|
||||
const folderName = parts[0] || '未知文件夹'
|
||||
|
||||
if (!folderMap.has(folderName)) {
|
||||
folderMap.set(folderName, [])
|
||||
}
|
||||
|
||||
folderMap.get(folderName)?.push(file)
|
||||
}
|
||||
|
||||
// 只展示文件夹名称,实际上传依然包含 folderFiles
|
||||
for (const [folderName, files] of folderMap.entries()) {
|
||||
const totalSize = files.reduce((acc, file) => acc + file.size, 0)
|
||||
selectedItems.value.push({ type: 'folder', name: folderName, size: totalSize, files })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
if (e.dataTransfer?.files) {
|
||||
const droppedFiles = Array.from(e.dataTransfer.files)
|
||||
droppedFiles.forEach(file => selectedItems.value.push({ type: 'file', name: file.name, size: file.size, files: [file] }))
|
||||
}
|
||||
}
|
||||
|
||||
const hasFiles = computed(() => selectedItems.value.length > 0)
|
||||
const allFiles = computed(() => selectedItems.value.flatMap(item => item.files || []))
|
||||
|
||||
function removeItem(index: number) {
|
||||
selectedItems.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function handleUpload() {
|
||||
uploadTaskManagerStore.addFiles(allFiles.value, props.bucketName, props.prefix)
|
||||
selectedItems.value = []
|
||||
closeModal()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<n-button text v-if="total > 0" @click="toggleDrawer">
|
||||
<div v-if="pending.length">
|
||||
<n-spin size="small" />
|
||||
<span>{{ total }} 任务进行中(进行中 {{ uploading.length }} 个,已成功 {{ completed.length }} 个)</span>
|
||||
</div>
|
||||
<div v-else>上传完成(成功 {{ completed.length }} 个,失败 {{ failed.length }} 个)</div>
|
||||
</n-button>
|
||||
|
||||
<n-drawer v-model:show="showDrawer" :width="502">
|
||||
<n-drawer-content title="任务管理" closable>
|
||||
<div class="flex flex-col gap-4">
|
||||
<n-alert type="warning" :show-icon="false">
|
||||
<p><span class="text-red-500">刷新/关闭浏览器</span> 将取消当前所有任务。</p>
|
||||
<p><span class="text-red-500">清空浏览器缓存、session过期</span>等情况会导致任务中断或丢失,请谨慎操作。</p>
|
||||
</n-alert>
|
||||
<div v-if="total > 0">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>{{ total - (pending.length + uploading.length) }}/{{ total }}</div>
|
||||
<n-button text type="info" @click="clearTasks">清空记录</n-button>
|
||||
</div>
|
||||
<n-progress type="line" :height="2" :percentage="percentage" :show-indicator="false" :processing="percentage < 100" />
|
||||
</div>
|
||||
<n-tabs type="line" animated>
|
||||
<n-tab-pane :tab="`待处理(${pending.length})`" name="pending">
|
||||
<object-upload-task-list :tasks="pending" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane :tab="`进行中(${uploading.length})`" name="uploading">
|
||||
<object-upload-task-list :tasks="uploading" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane :tab="`已完成(${completed.length})`" name="completed">
|
||||
<object-upload-task-list :tasks="completed" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane :tab="`已失败(${failed.length})`" name="failed">
|
||||
<object-upload-task-list :tasks="failed" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useUploadTaskManagerStore } from '~/store/upload-tasks'
|
||||
|
||||
const showDrawer = ref(false)
|
||||
|
||||
const store = useUploadTaskManagerStore()
|
||||
|
||||
const tasks = computed(() => store.tasks)
|
||||
const total = computed(() => tasks.value.length)
|
||||
const percentage = computed(() => total.value === 0 ? 100 : Math.floor((completed.value.length / total.value) * 100))
|
||||
|
||||
const pending = computed(() => tasks.value.filter(task => task.status === 'pending'))
|
||||
const uploading = computed(() => tasks.value.filter(task => task.status === 'uploading'))
|
||||
const completed = computed(() => tasks.value.filter(task => task.status === 'completed'))
|
||||
const failed = computed(() => tasks.value.filter(task => task.status === 'failed'))
|
||||
|
||||
const toggleDrawer = () => {
|
||||
showDrawer.value = !showDrawer.value
|
||||
}
|
||||
|
||||
const clearTasks = () => {
|
||||
store.clearTasks()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>{{ task.file.name }}</div>
|
||||
<n-button text @click="handleDeleteTask" type="info">删除记录</n-button>
|
||||
</div>
|
||||
<n-progress type="line" :height="2" :percentage="task.progress" :show-indicator="false" :processing="task.status == 'uploading'" />
|
||||
<div class="flex items-center justify-between text-muted-foreground">
|
||||
<div>{{ formatBytes(task.file.size) }}</div>
|
||||
<div class="text-muted-foreground">
|
||||
<span v-if="task.status === 'pending'">等待上传</span>
|
||||
<span v-else-if="task.status === 'uploading'">上传中</span>
|
||||
<span v-else-if="task.status === 'completed'">上传成功</span>
|
||||
<span v-else-if="task.status === 'failed'">上传失败</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { UploadTask } from '~/lib/upload-task-manager'
|
||||
import { useUploadTaskManagerStore } from '~/store/upload-tasks'
|
||||
|
||||
const store = useUploadTaskManagerStore()
|
||||
|
||||
const props = defineProps<{ task: UploadTask }>()
|
||||
|
||||
const handleDeleteTask = () => {
|
||||
store.removeTask(props.task.id)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div v-if="tasks.length <= 0" class="mx-auto text-muted-foreground text-center">暂无任务</div>
|
||||
<object-upload-task-item v-for="task in tasks" :key="task.id" :task="task" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { UploadTask } from '~/lib/upload-task-manager'
|
||||
defineProps<{ tasks: UploadTask[] }>()
|
||||
</script>
|
||||
Reference in New Issue
Block a user