feat: 上传

This commit is contained in:
overtrue
2024-12-22 16:18:50 +08:00
parent e1e52dbfac
commit f975d9ea63
13 changed files with 525 additions and 10 deletions
+8
View File
@@ -0,0 +1,8 @@
/* 实现列表头滚动固定 */
.n-list.sticky-header {
@apply relative;
}
.n-list.sticky-header .n-list__header {
@apply bg-neutral-900/60 sticky top-0 z-10;
}
+6
View File
@@ -7,6 +7,7 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
NAlert: typeof import('naive-ui')['NAlert']
NAvatar: typeof import('naive-ui')['NAvatar']
NButton: typeof import('naive-ui')['NButton']
NButtonGroup: typeof import('naive-ui')['NButtonGroup']
@@ -17,6 +18,8 @@ declare module 'vue' {
NDescriptions: typeof import('naive-ui')['NDescriptions']
NDescriptionsItem: typeof import('naive-ui')['NDescriptionsItem']
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
NDrawer: typeof import('naive-ui')['NDrawer']
NDrawerContent: typeof import('naive-ui')['NDrawerContent']
NDropdown: typeof import('naive-ui')['NDropdown']
NFlex: typeof import('naive-ui')['NFlex']
NForm: typeof import('naive-ui')['NForm']
@@ -28,12 +31,15 @@ declare module 'vue' {
NLayoutFooter: typeof import('naive-ui')['NLayoutFooter']
NLayoutHeader: typeof import('naive-ui')['NLayoutHeader']
NLayoutSider: typeof import('naive-ui')['NLayoutSider']
NList: typeof import('naive-ui')['NList']
NListItem: typeof import('naive-ui')['NListItem']
NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider']
NMenu: typeof import('naive-ui')['NMenu']
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NModal: typeof import('naive-ui')['NModal']
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
NPageHeader: typeof import('naive-ui')['NPageHeader']
NProgress: typeof import('naive-ui')['NProgress']
NSelect: typeof import('naive-ui')['NSelect']
NSpace: typeof import('naive-ui')['NSpace']
NSpin: typeof import('naive-ui')['NSpin']
+15 -1
View File
@@ -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/'
+2 -1
View File
@@ -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>
-6
View File
@@ -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">
+148
View File
@@ -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>
+66
View File
@@ -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>
+31
View File
@@ -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>
+11
View File
@@ -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>
+168
View File
@@ -0,0 +1,168 @@
import { CompleteMultipartUploadCommand, CreateMultipartUploadCommand, PutObjectCommand, S3Client, UploadPartCommand } from "@aws-sdk/client-s3";
export interface UploadTask {
id: string;
file: File;
bucketName: string;
prefix?: string;
status: "pending" | "uploading" | "completed" | "failed" | "canceled";
progress: number;
error?: string;
abortController?: AbortController;
}
export interface TaskManagerConfig {
chunkSize?: number; // in MB
maxConcurrentUploads?: number;
}
class UploadTaskManager {
private tasks = reactive<UploadTask[]>([]);
private maxConcurrentUploads: number;
private chunkSize: number;
private s3Client: S3Client;
private activeUploads: number = 0;
constructor(s3Client: S3Client, config: TaskManagerConfig) {
this.s3Client = s3Client;
this.chunkSize = (config.chunkSize ?? 5) * 1024 * 1024; // Default 5MB
this.maxConcurrentUploads = config.maxConcurrentUploads ?? 6;
}
addFiles(files: File[], bucketName: string, prefix?: string) {
files.forEach((file) => {
const task: UploadTask = {
id: `${Date.now()}-${file.name}`,
file,
status: "pending",
progress: 0,
bucketName,
prefix: prefix ?? "",
};
this.tasks.push(task);
});
console.debug("Added files to upload queue", files);
}
start() {
this.processQueue();
}
cancel() {
this.tasks
.filter((task) => task.status === "pending")
.forEach((task) => {
task.status = "canceled";
});
}
cancelTask(taskId: string) {
const task = this.tasks.find((task) => task.id === taskId);
if (task && task.abortController) {
task.abortController.abort();
task.status = "canceled";
}
}
removeTask(taskId: string) {
const taskIndex = this.tasks.findIndex((task) => task.id === taskId);
if (taskIndex !== -1) {
this.tasks.splice(taskIndex, 1);
}
}
getTasks() {
return this.tasks;
}
clearTasks() {
this.tasks = [];
}
private async processQueue() {
while (this.activeUploads < this.maxConcurrentUploads) {
const nextTask = this.tasks.find((task) => task.status === "pending");
if (!nextTask) { break; }
this.activeUploads++;
nextTask.status = "uploading";
try {
if (nextTask.file.size > this.chunkSize) {
await this.multipartUpload(nextTask);
} else {
await this.putObject(nextTask);
}
nextTask.status = "completed";
} catch (error: any) {
nextTask.status = "failed";
nextTask.error = error.message;
} finally {
this.activeUploads--;
this.processQueue(); // Continue with the next task
}
}
}
private async putObject(task: UploadTask) {
const { file, bucketName, prefix } = task;
const abortController = new AbortController();
task.abortController = abortController;
const command = new PutObjectCommand({
Bucket: bucketName,
Key: prefix + task.file.name,
Body: file,
});
await this.s3Client.send(command, { abortSignal: abortController.signal });
task.progress = 100;
}
private async multipartUpload(task: UploadTask) {
const { file, bucketName, prefix } = task;
const abortController = new AbortController();
task.abortController = abortController;
const Key = prefix + task.file.name;
const createCommand = new CreateMultipartUploadCommand({
Bucket: bucketName,
Key: Key
});
const { UploadId } = await this.s3Client.send(createCommand);
const totalChunks = Math.ceil(file.size / this.chunkSize);
let completedParts = [];
for (let partNumber = 1; partNumber <= totalChunks; partNumber++) {
const start = (partNumber - 1) * this.chunkSize;
const end = Math.min(start + this.chunkSize, file.size);
const chunk = file.slice(start, end);
const uploadPartCommand = new UploadPartCommand({
Bucket: bucketName,
Key: Key,
UploadId,
PartNumber: partNumber,
Body: chunk,
});
const { ETag } = await this.s3Client.send(uploadPartCommand, { abortSignal: abortController.signal });
completedParts.push({ ETag, PartNumber: partNumber });
task.progress = Math.round((partNumber / totalChunks) * 100);
}
const completeCommand = new CompleteMultipartUploadCommand({
Bucket: bucketName,
Key: Key,
UploadId,
MultipartUpload: { Parts: completedParts },
});
await this.s3Client.send(completeCommand);
}
}
export default UploadTaskManager;
+4 -1
View File
@@ -17,7 +17,10 @@ export default defineNuxtConfig({
'nuxtjs-naive-ui',
'@vueuse/nuxt'
],
plugins: ['~/plugins/api.ts', '~/plugins/s3.ts'],
plugins: [
'~/plugins/api.ts',
'~/plugins/s3.ts',
],
runtimeConfig: {
public: {
api: {
+31
View File
@@ -0,0 +1,31 @@
import { defineStore } from 'pinia'
import UploadTaskManager from '~/lib/upload-task-manager'
export const useUploadTaskManagerStore = defineStore('uploadTaskManager', {
state: () => ({
taskManager: new UploadTaskManager(useNuxtApp().$s3Client, {
chunkSize: 5,
maxConcurrentUploads: 6
})
}),
getters: {
tasks: (state) => state.taskManager.getTasks()
},
actions: {
addFiles(files: File[], bucketName: string, prefix?: string) {
this.taskManager.addFiles(files, bucketName, prefix)
this.taskManager.start()
},
getTasks() {
return this.taskManager.getTasks()
},
removeTask(taskId: string) {
this.taskManager.removeTask(taskId)
},
clearTasks() {
this.taskManager.clearTasks()
}
}
})
+35 -1
View File
@@ -12,7 +12,41 @@ export default {
theme: {
extend: {
colors: {
primary: 'rgba(var(--n-primary-color))',
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
// overwrite the naive-ui primary color
'primary-1': 'rgba(var(--n-primary-color-1))',
'primary-2': 'rgba(var(--n-primary-color-2))',
'primary-3': 'rgba(var(--n-primary-color-3))',