mirror of
https://github.com/keven1024/015.git
synced 2026-05-26 07:08:02 +00:00
feat(front): 增强文件上传组件,支持上传速度显示和动态进度更新,优化文件分块处理逻辑
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { cx } from 'class-variance-authority'
|
||||
export type filePreview = {
|
||||
type: string
|
||||
name: string
|
||||
@@ -8,6 +9,7 @@ export type filePreview = {
|
||||
import { LucideFileAudio, LucideFileVideo, LucideFile, LucideFileCode, LucideFileArchive, LucideFileText } from 'lucide-vue-next'
|
||||
const props = defineProps<{
|
||||
file: File | filePreview
|
||||
class?: string
|
||||
}>()
|
||||
const imageUrl = computed(() => {
|
||||
if (props?.file?.type?.startsWith('image/') && props?.file instanceof File) {
|
||||
@@ -30,15 +32,20 @@ const fileIcon = computed(() => {
|
||||
if (baseType === 'audio') {
|
||||
return LucideFileAudio
|
||||
}
|
||||
if (baseType === 'text' || ["json", "ld+json", "html"]?.includes(type)) {
|
||||
if (baseType === 'text' || ['json', 'ld+json', 'html']?.includes(type)) {
|
||||
return LucideFileCode
|
||||
}
|
||||
if (['pdf', 'msword',
|
||||
'vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'vnd.ms-excel',
|
||||
'vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'vnd.ms-powerpoint',
|
||||
'vnd.openxmlformats-officedocument.presentationml.presentation'].includes(type)) {
|
||||
if (
|
||||
[
|
||||
'pdf',
|
||||
'msword',
|
||||
'vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'vnd.ms-excel',
|
||||
'vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'vnd.ms-powerpoint',
|
||||
'vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
].includes(type)
|
||||
) {
|
||||
return LucideFileText
|
||||
}
|
||||
if (['zip', 'vnd.rar', 'x-tar', 'gz', 'bz2', 'x-7z-compressed'].includes(type)) {
|
||||
@@ -51,11 +58,10 @@ const fileIcon = computed(() => {
|
||||
<template>
|
||||
<div v-if="!!imageUrl" class="flex max-w-30 max-h-20">
|
||||
<div class="object-contain m-auto h-full">
|
||||
<img :src="imageUrl" class="w-full h-full border border-black/20 rounded" />
|
||||
<img :src="imageUrl" class="w-full h-full border border-black/20 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!imageUrl" class="size-16 flex justify-center items-center rounded-xl bg-white/80">
|
||||
<component :is="fileIcon" class="size-10" />
|
||||
<div v-if="!imageUrl" :class="cx('flex justify-center items-center rounded-xl bg-white/80 size-16', props?.class)">
|
||||
<component :is="fileIcon" class="size-[62.5%]" />
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -22,7 +22,7 @@ const renderGraphics = (graphics: Graphics) => {
|
||||
graphics.clear()
|
||||
Object.entries(props?.data?.chunks || {})?.map(([index, item]) => {
|
||||
const { status, createdAt } = item || {}
|
||||
const size = props.data?.chunkLength / width.value
|
||||
const size = width.value / props.data?.chunkLength
|
||||
const x = Number(index) * size
|
||||
graphics.rect(x, 0, size, height.value)
|
||||
let color = 0x60a5fa
|
||||
|
||||
@@ -1,38 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { LucidePlay, LucideSettings, LucideSquare } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import dayjs from 'dayjs'
|
||||
import FileUploadBlockProgressView from '@/components/FileUploadBlockProgressView.vue'
|
||||
import { motion } from 'motion-v'
|
||||
import { filesize } from 'filesize'
|
||||
import { cx } from 'class-variance-authority'
|
||||
import asyncWait from '@/lib/asyncWait'
|
||||
import asyncWorker from '@/lib/asyncWorker'
|
||||
import calcFileHashWorker from '@/lib/calcFileHashWorker?worker'
|
||||
import { clamp, get, isEmpty, isNumber, isString, sample, shuffle, times } from 'lodash-es'
|
||||
import { clamp, get, groupBy, isEmpty, isNumber, isString, sample, shuffle, times } from 'lodash-es'
|
||||
import { nanoid } from 'nanoid'
|
||||
import asyncRetry from '@/lib/asyncRetry'
|
||||
import asyncTimeout from '@/lib/asyncTimeout'
|
||||
import { toast } from 'vue-sonner'
|
||||
import dayjs from 'dayjs'
|
||||
import showDrawer from '~/lib/showDrawer'
|
||||
import FileUploadSpeedInfoView from './FileUploadSpeedInfoView.vue'
|
||||
import getFileChunk from '~/lib/getFileChunk'
|
||||
|
||||
const props = defineProps<{
|
||||
data: { file: File[]; config: any; handle_type: string }
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'change', key: string): void
|
||||
}>()
|
||||
const form = useFormContext()
|
||||
|
||||
const selectedFile = ref()
|
||||
const uploadfiles = ref<
|
||||
{
|
||||
fileId: string
|
||||
id?: string // 后端文件id
|
||||
file: File
|
||||
status: 'start' | 'pause' | 'finish' | 'error'
|
||||
hash?: string
|
||||
procressType: 'hash' | 'create' | 'upload'
|
||||
procressType: 'hash' | 'create' | 'upload' | 'finish'
|
||||
uploadInfo?: {
|
||||
chunks: Record<number, { status: 'success' | 'error' | 'processing'; createdAt: number }>
|
||||
chunkLength: number
|
||||
ChunkSize: number
|
||||
}
|
||||
queue: {
|
||||
taskId: string
|
||||
taskType: 'hash' | 'create' | 'chunk' | 'upload'
|
||||
taskType: 'hash' | 'create' | 'chunk' | 'upload' | 'finish'
|
||||
queueType: 'sync' | 'async' // sync任务禁止并发
|
||||
index?: number
|
||||
retry?: number
|
||||
@@ -44,9 +51,53 @@ const selectedUploadfileChunk = computed(() => Object.values(selectedUploadfile.
|
||||
const selectedUploadfileViewMode = ref<'progress' | 'heatmap'>('progress')
|
||||
|
||||
const procressTaskList = ref<Map<string, any>>(new Map())
|
||||
const taskList = computed(() => uploadfiles.value.filter((r) => r.queue.length > 0 && r.status === 'start').flatMap((r) => r.queue))
|
||||
const activeTaskList = computed(() => uploadfiles.value.filter((r) => r.queue.length > 0 && r.status === 'start'))
|
||||
const activeTaskAllQueue = computed(() => activeTaskList.value.flatMap((r) => r.queue))
|
||||
const batchNum = ref(3)
|
||||
|
||||
const totalTaskStatus = computed(() => {
|
||||
if (uploadfiles.value.some((r) => r.status === 'start')) {
|
||||
return 'start'
|
||||
}
|
||||
if (uploadfiles.value.some((r) => r.status === 'pause')) {
|
||||
return 'pause'
|
||||
}
|
||||
return 'disabled'
|
||||
})
|
||||
const totalUploadProgress = computed(() => {
|
||||
const successCount = uploadfiles.value.reduce((acc, curr) => {
|
||||
const { status, uploadInfo } = curr || {}
|
||||
if (status === 'finish') return acc
|
||||
const { chunks } = uploadInfo || {}
|
||||
return acc + Object.entries(chunks || {}).filter(([index, chunk]) => chunk.status === 'success').length
|
||||
}, 0)
|
||||
const totalCount = uploadfiles.value.reduce((acc, curr) => {
|
||||
const { status, uploadInfo } = curr || {}
|
||||
if (status === 'finish') return acc
|
||||
const { chunkLength } = uploadInfo || {}
|
||||
return acc + (chunkLength || 0)
|
||||
}, 0)
|
||||
return ((successCount || 0) / (totalCount || 0)) * 100
|
||||
})
|
||||
|
||||
const counter = useInterval(1000)
|
||||
const speedChartData = ref<Record<number, { createdAt: number; value: number }[]>>({})
|
||||
watch(counter, () => {
|
||||
const speed = uploadfiles.value?.flatMap((item) => {
|
||||
const { chunks, ChunkSize } = item?.uploadInfo || {}
|
||||
return Object.entries(chunks || {})
|
||||
.filter(([index, chunk]) => chunk.status === 'success' && dayjs().unix() - 60 < chunk.createdAt)
|
||||
?.map(([index, chunk]) => {
|
||||
const { createdAt } = chunk || {}
|
||||
return {
|
||||
createdAt,
|
||||
value: ChunkSize || 0,
|
||||
}
|
||||
})
|
||||
})
|
||||
speedChartData.value = groupBy(speed, 'createdAt')
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
props.data.file.forEach((file) => {
|
||||
uploadfiles.value.push({
|
||||
@@ -65,8 +116,7 @@ onMounted(() => {
|
||||
|
||||
const { error, execute, isLoading } = useAsyncState(
|
||||
async () => {
|
||||
while (taskList.value.length > 0) {
|
||||
console.log('hasTask')
|
||||
while (activeTaskAllQueue.value.length > 0) {
|
||||
const taskList = uploadfiles?.value?.filter((r) => r.queue.length > 0 && r.status === 'start')
|
||||
await Promise.all(
|
||||
times(batchNum.value, async (i: number) => {
|
||||
@@ -74,30 +124,40 @@ const { error, execute, isLoading } = useAsyncState(
|
||||
const fileId = file?.fileId as string
|
||||
const task = get(file?.queue, '0')
|
||||
if (!task) return
|
||||
const { taskId, index, queueType, taskType } = task || {}
|
||||
const { taskId, index, queueType, taskType, retry } = task || {}
|
||||
if (procressTaskList.value.has(taskId)) return
|
||||
procressTaskList.value.set(taskId, task)
|
||||
|
||||
const uploadFileIndex = uploadfiles.value.findIndex((r) => r.fileId === file?.fileId)
|
||||
|
||||
if (queueType === 'async') {
|
||||
if (!!retry && retry >= 3) {
|
||||
toast.error('上传错误', {
|
||||
description: `文件 ${file?.file?.name} 的${index}分块经过多次尝试依然上传失败, 我们已经终止该文件上传`,
|
||||
})
|
||||
uploadfiles.value[uploadFileIndex].status = 'error'
|
||||
return
|
||||
}
|
||||
uploadfiles.value[uploadFileIndex]?.queue.shift()
|
||||
}
|
||||
console.log('[start]', taskType, taskId, queueType)
|
||||
// console.log('[start]', taskType, taskId, queueType)
|
||||
try {
|
||||
if (taskType === 'hash') {
|
||||
await asyncRetry(() => asyncTimeout(() => handleHash(fileId), 10000))
|
||||
await handleHash(fileId)
|
||||
}
|
||||
if (taskType === 'create') {
|
||||
await asyncRetry(() => asyncTimeout(() => handleCreate(fileId), 10000))
|
||||
await handleCreate(fileId)
|
||||
}
|
||||
if (taskType === 'chunk') {
|
||||
await asyncRetry(() => asyncTimeout(() => handleChunk(fileId), 10000))
|
||||
await handleChunk(fileId)
|
||||
}
|
||||
if (taskType === 'upload' && isNumber(index)) {
|
||||
await asyncRetry(() => asyncTimeout(() => handleUpload(fileId, index), 10000))
|
||||
await handleUpload(fileId, index)
|
||||
}
|
||||
console.log('[finish]', taskType, taskId)
|
||||
if (taskType === 'finish') {
|
||||
await handleFinish(fileId)
|
||||
}
|
||||
// console.log('[finish]', taskType, taskId)
|
||||
if (queueType === 'sync') {
|
||||
uploadfiles.value[uploadFileIndex]?.queue.shift()
|
||||
}
|
||||
@@ -106,9 +166,15 @@ const { error, execute, isLoading } = useAsyncState(
|
||||
// todo 重新加入队列
|
||||
if (queueType === 'async') {
|
||||
uploadfiles.value[uploadFileIndex]?.queue.push({ ...task, retry: (task?.retry || 0) + 1 })
|
||||
toast.warning('上传错误', {
|
||||
description: `文件 ${file?.file?.name} 的${index}分块上传失败, 我们将在稍后再次尝试上传`,
|
||||
})
|
||||
}
|
||||
if (queueType === 'sync') {
|
||||
uploadfiles.value[uploadFileIndex].status = 'error'
|
||||
toast.error('上传错误', {
|
||||
description: `文件${file?.file?.name}上传失败, 请重试`,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
procressTaskList.value.delete(taskId)
|
||||
@@ -122,10 +188,8 @@ const { error, execute, isLoading } = useAsyncState(
|
||||
)
|
||||
|
||||
watchEffect(async () => {
|
||||
if (taskList.value.length > 0 && !isLoading.value) {
|
||||
console.log('task队列已更新', `当前任务 ${taskList.value.length} 个 正在消化`)
|
||||
if (activeTaskAllQueue.value.length > 0 && !isLoading.value) {
|
||||
execute()
|
||||
console.log('开始执行任务...')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -141,22 +205,49 @@ const handleHash = async (fileId: string) => {
|
||||
const handleCreate = async (fileId: string) => {
|
||||
const uploadfile = uploadfiles.value.find((item) => item.fileId === fileId)
|
||||
if (!uploadfile?.file) return
|
||||
const { hash, file } = uploadfile || {}
|
||||
if (!hash) return
|
||||
uploadfile.procressType = 'create'
|
||||
await asyncWait(1000)
|
||||
console.log('create', fileId)
|
||||
const { size, type = 'application/octet-stream' } = file || {}
|
||||
const createData = await $fetch<{
|
||||
data: {
|
||||
id: string
|
||||
type: 'init' | 'already'
|
||||
chunk_size: number
|
||||
}
|
||||
}>('/api/file/create', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
size,
|
||||
mime_type: type || 'application/octet-stream',
|
||||
hash,
|
||||
},
|
||||
})
|
||||
const { id, chunk_size, type: createType } = createData?.data || {}
|
||||
uploadfile.id = id
|
||||
uploadfile.uploadInfo = {
|
||||
chunks: {},
|
||||
chunkLength: Math.ceil(size / chunk_size),
|
||||
ChunkSize: chunk_size,
|
||||
}
|
||||
if (createType !== 'init') {
|
||||
// 文件存在
|
||||
uploadfile.status = 'finish'
|
||||
uploadfile.procressType = 'finish'
|
||||
uploadfile.queue = []
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const handleChunk = async (fileId: string) => {
|
||||
const uploadfile = uploadfiles.value.find((item) => item.fileId === fileId)
|
||||
if (!uploadfile?.file) return
|
||||
uploadfile.procressType = 'upload'
|
||||
uploadfile.uploadInfo = {
|
||||
chunks: {},
|
||||
chunkLength: 1000,
|
||||
}
|
||||
const tasks = shuffle(times(1000, (i: number) => ({ taskType: 'upload' as const, queueType: 'async' as const, taskId: nanoid(), index: i })))
|
||||
const { chunkLength } = uploadfile.uploadInfo || {}
|
||||
const tasks = shuffle(
|
||||
times(chunkLength as number, (i: number) => ({ taskType: 'upload' as const, queueType: 'async' as const, taskId: nanoid(), index: i }))
|
||||
)
|
||||
uploadfile.queue.push(...tasks)
|
||||
console.log('chunk', fileId)
|
||||
}
|
||||
|
||||
const handleUpload = async (fileId: string, index: number) => {
|
||||
@@ -167,33 +258,108 @@ const handleUpload = async (fileId: string, index: number) => {
|
||||
if (!chunkInfo) {
|
||||
uploadfile.uploadInfo!.chunks[index] = {
|
||||
status: 'processing',
|
||||
createdAt: Date.now(),
|
||||
createdAt: dayjs().unix(),
|
||||
}
|
||||
}
|
||||
await asyncWait(500)
|
||||
if (index % 3 === 0) {
|
||||
uploadfile.uploadInfo!.chunks[index].status = 'error'
|
||||
const { id, uploadInfo } = uploadfile || {}
|
||||
const { chunkLength, ChunkSize } = uploadInfo || {}
|
||||
|
||||
const chunk = await getFileChunk(uploadfile.file, index, ChunkSize as number)
|
||||
const formData = new FormData()
|
||||
formData.append('file', new Blob([chunk]))
|
||||
formData.append('index', (index + 1).toString())
|
||||
formData.append('id', id as string)
|
||||
const res = await $fetch<{
|
||||
code: number
|
||||
}>('/api/file/slice', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
const { code } = res || {}
|
||||
if (code !== 200) {
|
||||
throw new Error('上传失败')
|
||||
}
|
||||
uploadfile.uploadInfo!.chunks[index].status = 'success'
|
||||
console.log('upload', fileId, index)
|
||||
if (Object.entries(uploadfile.uploadInfo!.chunks || {}).filter(([index, chunk]) => chunk.status === 'success').length === chunkLength) {
|
||||
uploadfile.queue.push({ taskType: 'finish', queueType: 'sync', taskId: nanoid() })
|
||||
}
|
||||
}
|
||||
|
||||
const handleFinish = async (fileId: string) => {
|
||||
const uploadfile = uploadfiles.value.find((item) => item.fileId === fileId)
|
||||
if (!uploadfile?.file) return
|
||||
uploadfile.procressType = 'finish'
|
||||
const { id } = uploadfile || {}
|
||||
const res = await $fetch<{
|
||||
code: number
|
||||
}>('/api/file/finish', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
id,
|
||||
},
|
||||
})
|
||||
if (res?.code !== 200) {
|
||||
throw new Error('上传失败')
|
||||
}
|
||||
uploadfile.status = 'finish'
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (!isEmpty(uploadfiles.value) && uploadfiles.value.every((r) => r.status === 'finish')) {
|
||||
// console.log('change', uploadfiles.value)
|
||||
form.setFieldValue(
|
||||
'files',
|
||||
uploadfiles.value.map((item) => {
|
||||
const { id, file } = item || {}
|
||||
return {
|
||||
id,
|
||||
file,
|
||||
}
|
||||
})
|
||||
)
|
||||
emit('change', 'result')
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
uploadfiles.value = []
|
||||
})
|
||||
|
||||
const handleShowSpeedInfo = () => {
|
||||
showDrawer({
|
||||
render: ({ hide }) => h(FileUploadSpeedInfoView, { hide }),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-4 gap-5">
|
||||
<div class="rounded-xl p-3 bg-white/80 flex flex-col gap-2 col-span-3">
|
||||
<div class="rounded-xl p-3 bg-white/80 flex flex-col gap-2 col-span-4 md:col-span-3 h-32 md:h-auto">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-xs opacity-70">总上传速度</div>
|
||||
<div class="text-2xl font-bold">144.0MB/s</div>
|
||||
<div @click="handleShowSpeedInfo" class="flex flex-row gap-1 items-center text-xs opacity-70">
|
||||
总上传进度 <LucideInfo class="size-3" />
|
||||
</div>
|
||||
<div class="text-2xl font-bold">
|
||||
{{
|
||||
`${
|
||||
filesize(
|
||||
Object.entries(speedChartData)
|
||||
?.filter((r) => dayjs().unix() - 60 < parseInt(r[0]))
|
||||
?.reduce((acc, curr) => acc + curr[1]?.reduce((_acc, _curr) => _acc + _curr.value, 0), 0) / 60
|
||||
) ?? 0
|
||||
}/s`
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 relative overflow-hidden flex flex-row gap-0.5 justify-end items-end">
|
||||
<motion.div
|
||||
class="w-2 shrink-0 bg-primary relative"
|
||||
:style="{ height: (i.value + 1) * 4 + 'px' }"
|
||||
:layoutId="i.time"
|
||||
v-for="i in data"
|
||||
:key="i.time"
|
||||
:style="{
|
||||
height: `${(i[1]?.reduce((acc, curr) => acc + curr.value, 0) / Math.max(...Object.entries(speedChartData)?.map((r) => r[1]?.reduce((acc, curr) => acc + curr.value, 0)))) * 100}%`,
|
||||
}"
|
||||
:layoutId="i[0]"
|
||||
v-for="i in Object.entries(speedChartData)"
|
||||
:key="i[0]"
|
||||
:initial="{ x: 10, opacity: 0 }"
|
||||
:animate="{ x: 0, opacity: 1 }"
|
||||
:transition="{ duration: 1 }"
|
||||
@@ -203,48 +369,61 @@ const handleUpload = async (fileId: string, index: number) => {
|
||||
:showLegend="false" :showXAxis="false" :showYAxis="false" :showGrid="false" :groupMaxWidth="10" /> -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-xl bg-white/80 aspect-square flex flex-col gap-2 relative overflow-hidden">
|
||||
<div class="rounded-xl col-span-4 md:col-span-1 bg-white/80 h-32 md:h-auto md:aspect-square flex flex-col gap-2 relative overflow-hidden">
|
||||
<div class="absolute top-0 left-0 w-full h-full z-[0] flex flex-col justify-end">
|
||||
<div class="w-full bg-green-100 h-1/2 border-t border-green-500"></div>
|
||||
<div class="w-full bg-green-100 border-t border-green-500" :style="`height: ${totalUploadProgress || 0}%`"></div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 justify-between p-3 h-full relative z-[1]">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-xs opacity-70">总上传进度</div>
|
||||
<div class="text-4xl font-bold">44.0%</div>
|
||||
<div class="text-4xl font-bold">{{ (totalUploadProgress || 0).toFixed(1) }}%</div>
|
||||
</div>
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button class="aspect-square bg-green-200 hover:bg-green-300 text-green-500 hover:text-white p-0">
|
||||
<Button
|
||||
class="aspect-square hover:text-white p-0 bg-green-200 hover:bg-green-300 text-green-500"
|
||||
:disabled="['start', 'disabled'].includes(totalTaskStatus)"
|
||||
@click="
|
||||
() => {
|
||||
uploadfiles.forEach((r) => {
|
||||
r.status = 'start'
|
||||
})
|
||||
}
|
||||
"
|
||||
>
|
||||
<LucidePlay class="size-1/2" />
|
||||
</Button>
|
||||
<Button
|
||||
class="aspect-square bg-red-200 hover:bg-red-300 text-red-500 hover:text-white p-0"
|
||||
class="aspect-square hover:text-white p-0 bg-orange-200 hover:bg-orange-300 text-orange-500"
|
||||
:disabled="['pause', 'disabled'].includes(totalTaskStatus)"
|
||||
@click="
|
||||
() => {
|
||||
console.log('暂停')
|
||||
uploadfiles.forEach((r) => {
|
||||
r.status = 'pause'
|
||||
})
|
||||
}
|
||||
"
|
||||
>
|
||||
<LucideSquare class="size-1/2" />
|
||||
</Button>
|
||||
<Button class="aspect-square bg-blue-200 hover:bg-blue-300 text-blue-500 hover:text-white p-0">
|
||||
<!-- <Button class="aspect-square bg-blue-200 hover:bg-blue-300 text-blue-500 hover:text-white p-0">
|
||||
<LucideSettings class="size-1/2" />
|
||||
</Button>
|
||||
</Button> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-4 flex flex-col bg-white/80 rounded-xl p-3 text-md gap-5">
|
||||
<div>文件列表</div>
|
||||
<div class="flex flex-col -mx-3 text-sm">
|
||||
<div class="grid grid-cols-[2fr_5rem_5rem_4fr] gap-2 border-b border-black/20 pb-2 px-3">
|
||||
<div class="grid grid-cols-[2fr_5rem_5rem] md:grid-cols-[2fr_5rem_5rem_4fr] gap-2 border-b border-black/20 pb-2 px-3">
|
||||
<div>文件名</div>
|
||||
<div>文件大小</div>
|
||||
<div>上传速度</div>
|
||||
<div>进度</div>
|
||||
<div @click="handleShowSpeedInfo" class="flex flex-row gap-1 items-center">上传速度 <LucideInfo class="size-3" /></div>
|
||||
<div class="hidden md:block">进度</div>
|
||||
</div>
|
||||
<div
|
||||
:class="
|
||||
cx(
|
||||
'grid grid-cols-[2fr_5rem_5rem_4fr] gap-2 py-2 border-b border-black/20 items-center hover:bg-primary/30 px-3 cursor-pointer',
|
||||
'grid grid-cols-[2fr_5rem_5rem] md:grid-cols-[2fr_5rem_5rem_4fr] gap-2 py-2 border-b border-black/20 items-center hover:bg-primary/30 px-3 cursor-pointer',
|
||||
selectedFile === item?.fileId && 'bg-primary text-white hover:!bg-primary'
|
||||
)
|
||||
"
|
||||
@@ -259,10 +438,11 @@ const handleUpload = async (fileId: string, index: number) => {
|
||||
}
|
||||
"
|
||||
>
|
||||
<div class="flex flex-row gap-2 items-center grow-0 overflow-hidden">
|
||||
<div class="flex flex-row gap-2 items-center grow md:grow-0 overflow-hidden">
|
||||
<Button
|
||||
class="size-8 p-0 hover:bg-white/50"
|
||||
variant="ghost"
|
||||
:disabled="['finish']?.includes(item?.procressType)"
|
||||
@click="
|
||||
(e: Event) => {
|
||||
e.stopPropagation()
|
||||
@@ -274,28 +454,57 @@ const handleUpload = async (fileId: string, index: number) => {
|
||||
}
|
||||
"
|
||||
>
|
||||
<LucidePlay class="size-4 text-green-500" v-if="item?.status === 'start'" />
|
||||
<LucidePause class="size-4 text-orange-500" v-if="item?.status === 'pause'" />
|
||||
<LucideArrowUpFromLine class="size-4 text-green-500" v-if="item?.status === 'start'" />
|
||||
<LucideSquare class="size-4 text-gray-500" v-if="item?.status === 'pause'" />
|
||||
<LucideCircleX class="size-4 text-red-500" v-if="item?.status === 'error'" />
|
||||
<LucideCheckCircle class="size-4 text-green-500" v-if="item?.status === 'finish'" />
|
||||
</Button>
|
||||
<div class="truncate">{{ item?.file?.name }}</div>
|
||||
</div>
|
||||
<div>{{ filesize(item?.file?.size) }}</div>
|
||||
<div>100MB/s</div>
|
||||
<div class="flex flex-row gap-2 items-center" v-if="item?.procressType !== 'upload'">
|
||||
<div>
|
||||
{{
|
||||
`${filesize(
|
||||
(Object.entries(item?.uploadInfo?.chunks || {})?.filter(
|
||||
([, chunk]) => chunk.status === 'success' && dayjs().unix() - 60 < chunk.createdAt
|
||||
)?.length /
|
||||
60) *
|
||||
(item?.uploadInfo?.ChunkSize || 0)
|
||||
)}/s`
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-row gap-2 items-center col-span-3 md:col-span-1"
|
||||
v-if="['hash', 'create', 'chunk']?.includes(item?.procressType)"
|
||||
>
|
||||
<LucideLoaderCircle class="size-4 animate-spin" />
|
||||
<div>正在{{ item?.procressType }}中...</div>
|
||||
</div>
|
||||
<div class="flex flex-row gap-2 items-center" v-else>
|
||||
<div class="flex flex-row gap-2 items-center col-span-3 md:col-span-1" v-if="item?.procressType === 'finish'">
|
||||
{{ item?.status === 'finish' ? '云端已有相同hash文件, 秒传成功' : null }}
|
||||
{{ item?.status === 'error' ? '上传失败,请稍后重试' : null }}
|
||||
</div>
|
||||
<div class="flex flex-row gap-2 items-center col-span-3 md:col-span-1" v-if="item?.procressType === 'upload'">
|
||||
<div class="rounded-full bg-white/50 w-full h-2 overflow-hidden border border-white">
|
||||
<div
|
||||
class="h-full bg-primary"
|
||||
:style="`width: ${clamp(((Object.keys(item?.uploadInfo?.chunks || {})?.length || 0) / (item?.uploadInfo?.chunkLength || 0)) * 100, 0, 100)}%`"
|
||||
:style="`width: ${clamp(
|
||||
((Object.entries(item?.uploadInfo?.chunks || {})?.filter(([index, chunk]) => chunk.status === 'success')
|
||||
?.length || 0) /
|
||||
(item?.uploadInfo?.chunkLength || 0)) *
|
||||
100,
|
||||
0,
|
||||
100
|
||||
)}%`"
|
||||
></div>
|
||||
</div>
|
||||
<div>
|
||||
{{
|
||||
clamp(
|
||||
((Object.keys(item?.uploadInfo?.chunks || {})?.length || 0) / (item?.uploadInfo?.chunkLength || 0)) * 100,
|
||||
((Object.entries(item?.uploadInfo?.chunks || {})?.filter(([index, chunk]) => chunk.status === 'success')
|
||||
?.length || 0) /
|
||||
(item?.uploadInfo?.chunkLength || 0)) *
|
||||
100,
|
||||
0,
|
||||
100
|
||||
)?.toFixed(2)
|
||||
@@ -308,7 +517,9 @@ const handleUpload = async (fileId: string, index: number) => {
|
||||
<div class="col-span-4 flex flex-col bg-white/80 rounded-xl p-3 gap-5" v-if="selectedFile">
|
||||
<div>上传详情</div>
|
||||
<div class="grid grid-cols-3 text-sm gap-3">
|
||||
<div>区块: {{ selectedUploadfile?.uploadInfo?.chunkLength }} x 256.0 KiB</div>
|
||||
<div>
|
||||
区块: {{ selectedUploadfile?.uploadInfo?.chunkLength }} x {{ filesize(selectedUploadfile?.uploadInfo?.ChunkSize as number) }}
|
||||
</div>
|
||||
<div class="truncate col-span-2">hash: {{ selectedUploadfile?.hash }}</div>
|
||||
<div>已完成: {{ selectedUploadfileChunk?.filter((r) => r.status === 'success')?.length || 0 }}</div>
|
||||
<div>已丢弃: {{ selectedUploadfileChunk?.filter((r) => r.status === 'error')?.length || 0 }}</div>
|
||||
|
||||
8
front/components/Home/File/FileUploadSpeedInfoView.vue
Normal file
8
front/components/Home/File/FileUploadSpeedInfoView.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 pt-3">
|
||||
<div class="text-xl font-bold">上传速度如何计算</div>
|
||||
<div class="opacity-75">
|
||||
上传速度根据当前秒上传了 <b>文件区块的数量</b> * <b>每个文件区块的大小</b> 估算而来,可能与真实上传速度有一定的误差,仅供参考
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,152 +1,215 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from "@/components/ui/button";
|
||||
import FilePreviewView from "@/components/FilePreviewView.vue";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
import { toast } from "vue-sonner";
|
||||
import { useQuery } from "@tanstack/vue-query";
|
||||
import useMyAppShare from "@/composables/useMyAppShare";
|
||||
import useMyAppConfig from "@/composables/useMyAppConfig";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import "dayjs/locale/zh-cn"; // 导入中文语言包
|
||||
import showDrawer from "@/lib/showDrawer";
|
||||
import QrCoreDrawer from "@/components/Drawer/QrCoreDrawer.vue";
|
||||
dayjs.extend(relativeTime); // 扩展 relativeTime 插件
|
||||
dayjs.locale("zh-cn"); // 设置语言为中文
|
||||
import { Button } from '@/components/ui/button'
|
||||
import FilePreviewView from '@/components/FilePreviewView.vue'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import useMyAppShare from '@/composables/useMyAppShare'
|
||||
import useMyAppConfig from '@/composables/useMyAppConfig'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import 'dayjs/locale/zh-cn' // 导入中文语言包
|
||||
import showDrawer from '@/lib/showDrawer'
|
||||
import QrCoreDrawer from '@/components/Drawer/QrCoreDrawer.vue'
|
||||
import { h } from 'vue'
|
||||
import { cx } from 'class-variance-authority'
|
||||
dayjs.extend(relativeTime) // 扩展 relativeTime 插件
|
||||
dayjs.locale('zh-cn') // 设置语言为中文
|
||||
|
||||
const props = defineProps<{
|
||||
data: { file: File; config: any; handle_type: string; file_id: string };
|
||||
}>();
|
||||
data: { files: { id: string; file: File }[]; config: any; handle_type: string }
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: "change", key: string): void;
|
||||
}>();
|
||||
const { createFileShare } = useMyAppShare();
|
||||
(e: 'change', key: string): void
|
||||
}>()
|
||||
const { createFileShare } = useMyAppShare()
|
||||
const { data } = useQuery({
|
||||
queryKey: ["create-share", props?.data?.file_id],
|
||||
queryFn: async () => {
|
||||
const { file_id, config, file } = props?.data || {};
|
||||
const { name } = file || {};
|
||||
const data = await createFileShare({
|
||||
file_id,
|
||||
config,
|
||||
file_name: name,
|
||||
});
|
||||
return data?.data;
|
||||
},
|
||||
});
|
||||
queryKey: ['create-share', ...props?.data?.files?.map((item) => item.id)],
|
||||
queryFn: async () => {
|
||||
const { files, config } = props?.data || {}
|
||||
const data = await createFileShare({
|
||||
files: files?.map((item) => {
|
||||
const { id, file } = item || {}
|
||||
return { id, name: file.name }
|
||||
}),
|
||||
config,
|
||||
})
|
||||
return data?.map((item) => item?.data)
|
||||
},
|
||||
})
|
||||
|
||||
const appConfig = useMyAppConfig();
|
||||
const url = computed(() => {
|
||||
const { id } = data?.value || {};
|
||||
return `${appConfig?.value?.site_url}/s/${id}`;
|
||||
});
|
||||
const selectedFile = ref<string | undefined>()
|
||||
const selectedFileShare = computed(() => {
|
||||
return data?.value?.find((item) => item?.id === selectedFile.value)
|
||||
})
|
||||
watchEffect(() => {
|
||||
if (data?.value && data?.value?.length === 1 && !!data?.value?.[0]?.id) {
|
||||
selectedFile.value = data.value[0].id
|
||||
}
|
||||
})
|
||||
|
||||
const { copy } = useClipboard();
|
||||
watchEffect(() => {
|
||||
console.log('data', data?.value)
|
||||
})
|
||||
|
||||
const appConfig = useMyAppConfig()
|
||||
const getShareUrl = (id: string) => {
|
||||
return `${appConfig?.value?.site_url}/s/${id}`
|
||||
}
|
||||
|
||||
const { copy } = useClipboard()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<h2 class="text-lg">上传成功</h2>
|
||||
<div class="flex flex-col gap-3 items-center">
|
||||
<div class="flex flex-col h-30 items-center">
|
||||
<FilePreviewView :value="props?.data?.file" />
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col md:flex-row gap-5 rounded-md p-5 bg-white/20 backdrop-blur-xl w-full"
|
||||
>
|
||||
<div class="flex flex-col gap-2 flex-1">
|
||||
<div class="text-sm font-semibold">信息</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="rounded-xl flex flex-col bg-black/10 px-3 py-2 gap-1">
|
||||
<div class="text-xs font-semibold">下载次数</div>
|
||||
<div class="text-3xl font-light">{{ data?.download_nums }}</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<h2 class="text-lg">上传成功</h2>
|
||||
<div class="flex flex-col gap-3 items-center">
|
||||
<div v-if="data?.length === 1" class="flex flex-col h-30 items-center">
|
||||
<FilePreviewView :value="props?.data?.files?.[0]?.file" />
|
||||
</div>
|
||||
<div class="rounded-xl flex flex-col bg-black/5 px-3 py-2 gap-1">
|
||||
<div class="text-xs font-semibold">过期时间</div>
|
||||
<div class="text-md font-light">
|
||||
{{
|
||||
dayjs(data?.expire_at * 1000).format("YYYY-MM-DD HH:mm:ss")
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-xl flex flex-col bg-black/10 px-3 py-2 gap-1"
|
||||
v-if="data?.pickup_code"
|
||||
>
|
||||
<div class="flex flex-row justify-between w-full items-center">
|
||||
<div class="text-xs font-semibold">提取码</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="bg-white/70 p-0 size-6"
|
||||
size="icon"
|
||||
@click="
|
||||
() => {
|
||||
copy(data?.pickup_code);
|
||||
toast.success('复制成功');
|
||||
}
|
||||
"
|
||||
<div v-else class="flex flex-col gap-2 w-full p-5 bg-white/20 backdrop-blur-xl rounded-md">
|
||||
<div class="text-sm font-semibold">文件列表</div>
|
||||
<div
|
||||
v-for="file in data"
|
||||
:class="
|
||||
cx(
|
||||
'flex flex-row justify-between items-center gap-1 rounded-md p-2 border border-black/10 w-full cursor-pointer',
|
||||
selectedFile === file?.id && 'bg-primary text-white'
|
||||
)
|
||||
"
|
||||
@click="selectedFile = file?.id"
|
||||
>
|
||||
<LucideCopy class="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-row gap-2">
|
||||
<div v-for="s in data?.pickup_code" class="text-2xl font-light">
|
||||
{{ s }}
|
||||
<div class="flex flex-row items-center gap-2 flex-1 min-w-0">
|
||||
<FileIcon
|
||||
:file="props?.data?.files?.[data?.findIndex((i) => i?.id === file?.id) as number]?.file"
|
||||
:class="cx('!size-7 !rounded-md shrink-0', selectedFile === file?.id && '!bg-white/50')"
|
||||
/>
|
||||
<div class="text-sm flex-1 truncate">{{ file?.file_name }}</div>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
:class="cx('bg-white/70', selectedFile === file?.id && '!bg-white/30 border-none hover:text-white/80')"
|
||||
size="icon"
|
||||
@click="
|
||||
() => {
|
||||
copy(getShareUrl(file?.id as string))
|
||||
toast.success('复制成功')
|
||||
}
|
||||
"
|
||||
>
|
||||
<LucideCopy />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
:class="cx('bg-white/70', selectedFile === file?.id && '!bg-white/30 border-none hover:text-white/80')"
|
||||
size="icon"
|
||||
@click="
|
||||
() => {
|
||||
showDrawer({
|
||||
render: ({ ...rest }) =>
|
||||
h(QrCoreDrawer, {
|
||||
...rest,
|
||||
data: getShareUrl(file?.id as string),
|
||||
}),
|
||||
})
|
||||
}
|
||||
"
|
||||
>
|
||||
<LucideQrCode />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-5 flex-1">
|
||||
<div class="text-sm font-semibold">链接</div>
|
||||
<div class="flex flex-row gap-2">
|
||||
<Input v-model="url" class="bg-white/70" readonly />
|
||||
<Button
|
||||
variant="outline"
|
||||
class="bg-white/70"
|
||||
size="icon"
|
||||
@click="
|
||||
() => {
|
||||
copy(url);
|
||||
toast.success('复制成功');
|
||||
}
|
||||
"
|
||||
>
|
||||
<LucideCopy />
|
||||
</Button>
|
||||
<div v-if="!!selectedFileShare" class="flex flex-col md:flex-row gap-5 rounded-md p-5 bg-white/20 backdrop-blur-xl w-full">
|
||||
<div class="flex flex-col gap-2 flex-1">
|
||||
<div class="text-sm font-semibold">信息</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="rounded-xl flex flex-col bg-black/10 px-3 py-2 gap-1">
|
||||
<div class="text-xs font-semibold">下载次数</div>
|
||||
<div class="text-3xl font-light">{{ selectedFileShare?.download_nums }}</div>
|
||||
</div>
|
||||
<div class="rounded-xl flex flex-col bg-black/10 px-3 py-2 gap-1">
|
||||
<div class="text-xs font-semibold">过期时间</div>
|
||||
<div class="text-md font-light">
|
||||
{{ dayjs((selectedFileShare?.expire_at || 0) * 1000).format('YYYY-MM-DD HH:mm:ss') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-xl flex flex-col bg-black/10 px-3 py-2 gap-1" v-if="selectedFileShare?.pickup_code">
|
||||
<div class="flex flex-row justify-between w-full items-center">
|
||||
<div class="text-xs font-semibold">提取码</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="bg-white/70 p-0 size-6"
|
||||
size="icon"
|
||||
@click="
|
||||
() => {
|
||||
copy(selectedFileShare?.pickup_code as string)
|
||||
toast.success('复制成功')
|
||||
}
|
||||
"
|
||||
>
|
||||
<LucideCopy class="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-row gap-2">
|
||||
<div v-for="s in selectedFileShare?.pickup_code" class="text-2xl font-light">
|
||||
{{ s }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-5 flex-1">
|
||||
<div class="text-sm font-semibold">链接</div>
|
||||
<div class="flex flex-row gap-2">
|
||||
<Input :model-value="getShareUrl(selectedFileShare?.id as string)" class="bg-white/70" readonly />
|
||||
<Button
|
||||
variant="outline"
|
||||
class="bg-white/70"
|
||||
size="icon"
|
||||
@click="
|
||||
() => {
|
||||
copy(getShareUrl(selectedFileShare?.id as string))
|
||||
toast.success('复制成功')
|
||||
}
|
||||
"
|
||||
>
|
||||
<LucideCopy />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
class="bg-white/70"
|
||||
size="icon"
|
||||
@click="
|
||||
() => {
|
||||
showDrawer({
|
||||
render: ({ ...rest }) =>
|
||||
h(QrCoreDrawer, {
|
||||
...rest,
|
||||
data: getShareUrl(selectedFileShare?.id as string),
|
||||
}),
|
||||
})
|
||||
}
|
||||
"
|
||||
>
|
||||
<LucideQrCode />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="bg-white/70"
|
||||
size="icon"
|
||||
@click="
|
||||
() => {
|
||||
showDrawer({
|
||||
render: ({ ...rest }) =>
|
||||
h(QrCoreDrawer, {
|
||||
...rest,
|
||||
data: url,
|
||||
}),
|
||||
});
|
||||
}
|
||||
"
|
||||
class="w-40 hover:bg-primary/90"
|
||||
@click="
|
||||
() => {
|
||||
emit('change', 'input')
|
||||
}
|
||||
"
|
||||
>
|
||||
<LucideQrCode />
|
||||
返回首页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
class="hover:bg-white/50 w-40"
|
||||
@click="
|
||||
() => {
|
||||
emit('change', 'input');
|
||||
}
|
||||
"
|
||||
>
|
||||
确定
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { times } from 'lodash-es'
|
||||
import { toast } from 'vue-sonner'
|
||||
declare const window: any
|
||||
let shareIdTokenMap: WeakMap<{ share_id: string }, string>
|
||||
@@ -65,7 +66,7 @@ const createShare = async (data: any) => {
|
||||
}
|
||||
|
||||
const createFileShare = async (data: {
|
||||
file_id: string
|
||||
files: { id: string; name: string }[]
|
||||
config: {
|
||||
download_nums: number
|
||||
expire_time: number
|
||||
@@ -75,15 +76,19 @@ const createFileShare = async (data: {
|
||||
password?: string
|
||||
notify_email?: string
|
||||
}
|
||||
file_name: string
|
||||
}) => {
|
||||
const { file_id, config, file_name } = data || {}
|
||||
return await createShare({
|
||||
type: 'file',
|
||||
data: file_id,
|
||||
config,
|
||||
file_name,
|
||||
})
|
||||
const { files, config } = data || {}
|
||||
return await Promise.all(
|
||||
times(files.length, async (i) => {
|
||||
const { id, name } = files[i]
|
||||
return await createShare({
|
||||
type: 'file',
|
||||
data: id,
|
||||
config,
|
||||
file_name: name,
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const createTextShare = async (data: { text: string; config: any }) => {
|
||||
|
||||
11
front/lib/getFileChunk.ts
Normal file
11
front/lib/getFileChunk.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
const getFileChunk = (file: File, start: number, chunk_size: number): Promise<ArrayBuffer> => {
|
||||
const fileReader = new FileReader()
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunk = file.slice(start, start + chunk_size)
|
||||
fileReader.onload = (e) => resolve(e.target?.result as ArrayBuffer)
|
||||
fileReader.onerror = reject
|
||||
fileReader.readAsArrayBuffer(chunk)
|
||||
})
|
||||
}
|
||||
|
||||
export default getFileChunk
|
||||
Reference in New Issue
Block a user