feat(front): add TextUploadView and FileUpload components for enhanced file and text sharing functionality

This commit is contained in:
keven1024
2025-05-16 19:38:39 +08:00
parent 6e52d35984
commit a7c8b409c8
5 changed files with 17 additions and 5 deletions

View File

@@ -0,0 +1,34 @@
<script lang="ts" setup>
import VeeForm from '@/components/VeeForm.vue'
import FileUploadInputFileView from './FileUploadInputFileView.vue'
import FileUploadProgressView from './FileUploadProgressView.vue'
import FileUploadResultView from './FileUploadResultView.vue'
const fileStepList = [
{ component: FileUploadInputFileView, key: 'input' },
{ component: FileUploadProgressView, key: 'progress' },
{ component: FileUploadResultView, key: 'result' },
]
const step = ref('input')
const renderComponent = computed(() => {
return fileStepList.find((item) => item.key === step.value)?.component
})
const formRef = ref<InstanceType<typeof VeeForm>>()
watch(() => step.value, (newVal) => {
if (newVal === 'input') {
formRef.value?.form?.resetForm()
formRef.value?.form?.setValues({ file: null })
}
})
</script>
<template>
<VeeForm ref="formRef" v-slot="{ values }" :keepValues="true">
<div class="rounded-xl p-5 bg-white/50 backdrop-blur-xl w-full lg:w-200">
<component :is="renderComponent" :data="values" @change="(key: string) => {
step = key
}" />
</div>
</VeeForm>
</template>

View File

@@ -0,0 +1,37 @@
<script lang="ts" setup>
import showDrawer from '~/lib/showDrawer'
import FileShareDrawer from '@/components/Drawer/FileShareDrawer.vue'
import FileUploadField from '@/components/Field/FileUploadField.vue'
import FormButton from '@/components/Field/FormButton.vue'
// const form = useFormContext()
const emit = defineEmits<{
(e: 'change', key: string): void
}>()
const handleFormSubmit = async (form: any) => {
const { file } = form?.values || {}
showDrawer({
render: ({ hide }) => h(FileShareDrawer, {
hide, file, onFileHandle: ({ type, config }) => {
form.setFieldValue('file_handle_type', type)
form.setFieldValue('config', config)
emit('change', 'progress')
}
})
})
}
</script>
<template>
<div class="gap-5 flex flex-col">
<div class="text-xl font-normal">上传文件</div>
<FileUploadField name="file" rules="required" />
<div class="flex flex-row gap-3">
<FormButton @click="handleFormSubmit">
<LucideShare class="size-4" />提交
</FormButton>
</div>
</div>
</template>

View File

@@ -0,0 +1,158 @@
<script lang="ts" setup>
import CircularProgress from '@/components/CircularProgress.vue'
import { chunk, shuffle, times } from 'lodash-es';
import { cx } from 'class-variance-authority'
import calcFileHash from '~/lib/calcFileHash';
import { filesize } from 'filesize';
const props = defineProps<{
data: { file: File, config: any, file_handle_type: string }
}>()
const emit = defineEmits<{
(e: 'change', key: string): void
}>()
const form = useFormContext()
const step = ref<'hash' | 'upload'>('hash')
const calcHashTime = ref(0)
const chunkSize = ref(0)
const fileSliceUploadStatusList = ref<{
status: string
index: number
}[]>([])
const successCount = computed(() => fileSliceUploadStatusList.value.filter((item) => item.status === 'success').length)
const alreadyUploadSize = computed(() => successCount.value * chunkSize.value)
const uploadProgress = computed(() => Math.round(alreadyUploadSize.value / (props?.data?.file?.size || 0) * 100))
useAsyncState(async () => {
const { file } = props.data || {}
if (!file) return
const { size, type } = file || {}
const now = Date.now()
const hash = await calcFileHash({ file })
if (hash) {
step.value = 'upload'
calcHashTime.value = Date.now() - now
}
const createData = await $fetch<{
data: {
id: string
type: 'init' | 'already'
chunk_size: number
}
}>('/api/file/create', {
method: 'POST',
body: {
size,
mime_type: type,
hash,
}
})
const { id, chunk_size, type: createType } = createData?.data || {}
if (createType !== 'init') {
// 文件存在
form.setFieldValue('file_id', id)
emit('change', 'result')
return;
}
chunkSize.value = chunk_size
const chunks = Math.ceil(size / chunk_size);
fileSliceUploadStatusList.value = times(chunks, (i) => ({
status: 'pending',
index: i
}))
const readChunk = (start: 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);
});
};
const chunkedUploadTasks = chunk(shuffle([...fileSliceUploadStatusList.value]), 3)
for (let i = 0; i < chunkedUploadTasks?.length; i++) {
await Promise.all(chunkedUploadTasks?.[i]?.map(async (item: any) => {
const { index } = item || {}
try {
const chunk = await readChunk(index * chunk_size);
// console.log('chunk', chunk)
const formData = new FormData()
formData.append('file', new Blob([chunk]))
formData.append('index', index + 1)
formData.append('id', id)
fileSliceUploadStatusList.value[index].status = 'uploading'
const res = await $fetch<{
code: number
}>('/api/file/slice', {
method: 'POST',
body: formData
})
const { code } = res || {}
if (code !== 200) {
throw new Error('上传失败')
}
fileSliceUploadStatusList.value[index].status = 'success'
} catch (error) {
console.log('error', error)
// fileSliceStatusList.value[index].status = 'error'
}
}))
}
const r = await $fetch<{
code: number
}>('/api/file/finish', {
method: 'POST',
body: {
id
}
})
if (r?.code !== 200) {
throw new Error('上传失败')
}
form.setFieldValue('file_id', id)
emit('change', 'result')
}, null)
</script>
<template>
<div class="flex flex-col gap-3">
<div class="text-xl font-normal">正在上传</div>
<div class="flex flex-col items-center gap-4 md:flex-row md:justify-evenly">
<div :class="cx('flex flex-row items-center gap-5', step !== 'hash' && 'opacity-50')">
<div class="flex flex-col gap-0.5 items-center">
<div class="text-xs opacity-50">1.计算hash</div>
<div class="text-3xl font-light">
<LucideLoaderCircle v-if="step === 'hash'" class="size-5 my-1 animate-spin" />
<div v-else>{{ calcHashTime }}ms</div>
</div>
</div>
</div>
<div :class="cx('flex flex-row items-center gap-5 min-w-32', step !== 'upload' && 'opacity-50')">
<CircularProgress :size="80" :value="step !== 'upload' ? 0 : uploadProgress" />
<div class="flex flex-col gap-0.5 items-center">
<div class="text-xs opacity-50">2.上传文件</div>
<div class="text-3xl font-light">{{ step !== 'upload' ? 0 : uploadProgress }}%</div>
<div class="text-sm opacity-50" v-if="alreadyUploadSize">{{ filesize(alreadyUploadSize) }} / {{ filesize(data?.file?.size) }}</div>
</div>
</div>
</div>
<div class="flex flex-row gap-2 items-baseline">
<div class="text-md font-normal">详细信息</div>
<div class="text-xs opacity-50" v-if="step === 'upload' && fileSliceUploadStatusList?.length > 0">当前正在上传分块{{ `${successCount}/${Math.ceil(data?.file?.size / chunkSize)}` }} 并发:3</div>
</div>
<div class="flex flex-row flex-wrap gap-1">
<div v-for="i in fileSliceUploadStatusList" :class="cx('rounded size-4 ',
i.status === 'pending' && 'bg-white/90',
i.status === 'uploading' && 'bg-yellow-500',
i.status === 'success' && 'bg-green-500',
)" />
</div>
</div>
</template>

View File

@@ -0,0 +1,29 @@
<script lang="ts" setup>
import FileShareResult from '@/components/Result/FileShareResult.vue'
const props = defineProps<{
data: { file: File, config: any, file_handle_type: string, file_id: string }
}>()
const emit = defineEmits<{
(e: 'change', key: string): void
}>()
// console.log(props.data)
const handleList = [
{ component: FileShareResult, key: 'file-share' },
// { component: FileShareResult, key: 'file-share' },
]
const handleComponent = computed(() => {
return handleList.find((item) => item.key === props?.data?.file_handle_type)?.component
})
</script>
<template>
<div class="">
<component :is="handleComponent" :data="data" @change="(key: string) => {
emit('change', key)
}" />
</div>
</template>

View File

@@ -0,0 +1,38 @@
<script lang="ts" setup>
import { get } from 'lodash-es'
import VeeForm from '@/components/VeeForm.vue'
import MarkdownInputField from '@/components/Field/MarkdownInputField.vue'
import FormButton from '@/components/Field/FormButton.vue'
import Button from '@/components/ui/button/Button.vue'
import showDrawer from '@/lib/showDrawer'
import { h } from 'vue'
import TextShareDrawer from '@/components/Drawer/TextShareDrawer.vue'
import { cx } from 'class-variance-authority'
</script>
<template>
<VeeForm v-slot="{ setValues, values }">
<div class="rounded-xl p-5 bg-white/50 backdrop-blur-xl w-full lg:w-200 gap-5 flex flex-col">
<div class="text-xl font-normal">输入文本</div>
<div class="relative">
<MarkdownInputField name="text" placeholder="使用我们的文本处理器轻松分享,翻译,总结,生成图片,询问大模型"
class="max-h-[50vh] min-h-40 overflow-y-auto max-w-full [&>*]:pr-10" rules="required" />
<Button variant="ghost" size="icon" :class="cx('absolute right-2 top-2 hover:bg-black/10 transition-all duration-300',
values.text?.length > 0 ? 'opacity-100' : 'opacity-0 pointer-events-none'
)" @click="() => {
setValues({ text: '' })
}">
<LucideX />
</Button>
</div>
<div class="flex flex-row gap-3">
<FormButton @click="async (form) => {
const { text } = form?.values || {}
showDrawer({ render: ({ hide }) => h(TextShareDrawer, { hide, text }) })
}">
<LucideShare class="size-4" />提交
</FormButton>
</div>
</div>
</VeeForm>
</template>