feat: 新增 S3 文件上传组件,支持文件大小和数量限制,优化上传流程
This commit is contained in:
143
src/components/upload/index.vue
Normal file
143
src/components/upload/index.vue
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import type { UploadCustomRequestOptions, UploadFileInfo } from 'naive-ui';
|
||||||
|
import { type UploadFetchOptions, uploadToS3 } from '@/utils/aws/s3';
|
||||||
|
|
||||||
|
defineOptions({ name: 'UploadS3' });
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue?: string[];
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
accept?: string;
|
||||||
|
multiple?: boolean;
|
||||||
|
maxSize?: number; // MB
|
||||||
|
maxFiles?: number;
|
||||||
|
fetchOptions: Partial<UploadFetchOptions>;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
modelValue: () => [],
|
||||||
|
accept: '*/*',
|
||||||
|
multiple: true,
|
||||||
|
maxSize: 10,
|
||||||
|
maxFiles: 5,
|
||||||
|
placeholder: '',
|
||||||
|
label: ''
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: string[]): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const fileList = ref<UploadFileInfo[]>([]);
|
||||||
|
|
||||||
|
// 将 modelValue 转换为文件列表显示
|
||||||
|
const initFileList = computed(() => {
|
||||||
|
return props.modelValue.map((fileId, index) => ({
|
||||||
|
id: fileId,
|
||||||
|
name: `文件 ${index + 1}`,
|
||||||
|
status: 'finished' as const,
|
||||||
|
url: fileId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 自定义上传请求
|
||||||
|
async function handleCustomRequest({ file, onProgress, onFinish, onError }: UploadCustomRequestOptions) {
|
||||||
|
try {
|
||||||
|
// 验证文件大小
|
||||||
|
const fileSizeMB = file.file!.size / 1024 / 1024;
|
||||||
|
if (fileSizeMB > props.maxSize) {
|
||||||
|
window.$message?.error(`文件大小不能超过 ${props.maxSize}MB`);
|
||||||
|
onError();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证文件数量
|
||||||
|
if (fileList.value.length >= props.maxFiles) {
|
||||||
|
window.$message?.error(`最多只能上传 ${props.maxFiles} 个文件`);
|
||||||
|
onError();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传到 S3
|
||||||
|
const options = {
|
||||||
|
fileName: file.name,
|
||||||
|
fileSize: file.file?.size,
|
||||||
|
businessType: props.fetchOptions.businessType,
|
||||||
|
...props.fetchOptions
|
||||||
|
} as UploadFetchOptions;
|
||||||
|
|
||||||
|
const fileId = await uploadToS3(file.file as File, {
|
||||||
|
fetchOptions: options,
|
||||||
|
onProgress: percent => {
|
||||||
|
onProgress({ percent });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新文件 ID 到 modelValue
|
||||||
|
const newFileIds = [...props.modelValue, fileId];
|
||||||
|
emit('update:modelValue', newFileIds);
|
||||||
|
|
||||||
|
onFinish();
|
||||||
|
window.$message?.success(`文件 ${file.name} 上传成功`);
|
||||||
|
} catch (error: any) {
|
||||||
|
window.$message?.error(error.message || '上传失败');
|
||||||
|
onError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除文件
|
||||||
|
function handleRemove({ file }: { file: UploadFileInfo }) {
|
||||||
|
const newFileIds = props.modelValue.filter(id => id !== file.id);
|
||||||
|
emit('update:modelValue', newFileIds);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传前验证
|
||||||
|
function beforeUpload({ file }: { file: UploadFileInfo }) {
|
||||||
|
const fileSizeMB = file.file!.size / 1024 / 1024;
|
||||||
|
if (fileSizeMB > props.maxSize) {
|
||||||
|
window.$message?.error(`文件大小不能超过 ${props.maxSize}MB`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="upload-s3">
|
||||||
|
<div v-if="label" class="mb-2 text-14px font-medium">{{ label }}</div>
|
||||||
|
<NUpload
|
||||||
|
v-model:file-list="fileList"
|
||||||
|
:default-file-list="initFileList"
|
||||||
|
:accept="accept"
|
||||||
|
:multiple="multiple"
|
||||||
|
:max="maxFiles"
|
||||||
|
:custom-request="handleCustomRequest"
|
||||||
|
:on-remove="handleRemove"
|
||||||
|
:on-before-upload="beforeUpload"
|
||||||
|
list-type="text"
|
||||||
|
show-download-button
|
||||||
|
>
|
||||||
|
<NButton>
|
||||||
|
<template #icon>
|
||||||
|
<icon-ic-round-upload class="text-icon" />
|
||||||
|
</template>
|
||||||
|
{{ placeholder || '选择文件' }}
|
||||||
|
</NButton>
|
||||||
|
</NUpload>
|
||||||
|
<div class="mt-2 text-12px text-gray-400">
|
||||||
|
<div v-if="maxSize">单个文件大小不超过 {{ maxSize }}MB</div>
|
||||||
|
<div v-if="maxFiles">最多上传 {{ maxFiles }} 个文件</div>
|
||||||
|
<div v-if="accept !== '*/*'">支持格式:{{ accept }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="css" scoped>
|
||||||
|
.upload-s3 {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
4
src/typings/components.d.ts
vendored
4
src/typings/components.d.ts
vendored
@@ -26,6 +26,7 @@ declare module 'vue' {
|
|||||||
IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
|
IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
|
||||||
IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
|
IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
|
||||||
IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
|
IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
|
||||||
|
IconIcRoundUpload: typeof import('~icons/ic/round-upload')['default']
|
||||||
IconLocalBanner: typeof import('~icons/local/banner')['default']
|
IconLocalBanner: typeof import('~icons/local/banner')['default']
|
||||||
IconLocalLogo: typeof import('~icons/local/logo')['default']
|
IconLocalLogo: typeof import('~icons/local/logo')['default']
|
||||||
IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
|
IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
|
||||||
@@ -97,6 +98,7 @@ declare module 'vue' {
|
|||||||
TableFilter: typeof import('./../components/table/table-filter.vue')['default']
|
TableFilter: typeof import('./../components/table/table-filter.vue')['default']
|
||||||
TableHeaderOperation: typeof import('./../components/advanced/table-header-operation.vue')['default']
|
TableHeaderOperation: typeof import('./../components/advanced/table-header-operation.vue')['default']
|
||||||
ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default']
|
ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default']
|
||||||
|
Upload: typeof import('./../components/upload/index.vue')['default']
|
||||||
WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']
|
WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,6 +119,7 @@ declare global {
|
|||||||
const IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
|
const IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
|
||||||
const IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
|
const IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
|
||||||
const IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
|
const IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
|
||||||
|
const IconIcRoundUpload: typeof import('~icons/ic/round-upload')['default']
|
||||||
const IconLocalBanner: typeof import('~icons/local/banner')['default']
|
const IconLocalBanner: typeof import('~icons/local/banner')['default']
|
||||||
const IconLocalLogo: typeof import('~icons/local/logo')['default']
|
const IconLocalLogo: typeof import('~icons/local/logo')['default']
|
||||||
const IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
|
const IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
|
||||||
@@ -188,5 +191,6 @@ declare global {
|
|||||||
const TableFilter: typeof import('./../components/table/table-filter.vue')['default']
|
const TableFilter: typeof import('./../components/table/table-filter.vue')['default']
|
||||||
const TableHeaderOperation: typeof import('./../components/advanced/table-header-operation.vue')['default']
|
const TableHeaderOperation: typeof import('./../components/advanced/table-header-operation.vue')['default']
|
||||||
const ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default']
|
const ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default']
|
||||||
|
const Upload: typeof import('./../components/upload/index.vue')['default']
|
||||||
const WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']
|
const WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']
|
||||||
}
|
}
|
||||||
78
src/utils/aws/s3.ts
Normal file
78
src/utils/aws/s3.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { toRefs } from 'vue';
|
||||||
|
import { client, safeClient } from '@/service/api';
|
||||||
|
|
||||||
|
interface UploadOptions {
|
||||||
|
fetchOptions: UploadFetchOptions;
|
||||||
|
onProgress?: (progress: number) => void;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UploadFetchOptions = CommonType.TreatyBody<typeof client.api.file_storage.upload_url.post>;
|
||||||
|
|
||||||
|
export async function uploadToS3(file: File, options: UploadOptions) {
|
||||||
|
const { onProgress, signal, fetchOptions } = options;
|
||||||
|
|
||||||
|
// 1. 获取预签名 URL
|
||||||
|
const { data, error } = await safeClient(
|
||||||
|
client.api.file_storage.upload_url.post({
|
||||||
|
...fetchOptions
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error.value || !data.value) {
|
||||||
|
throw new Error('获取上传 URL 失败');
|
||||||
|
}
|
||||||
|
const { fileId, uploadUrl, method, headers } = toRefs(data.value);
|
||||||
|
|
||||||
|
// 2. 上传文件到 S3
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
|
||||||
|
// 监听上传进度
|
||||||
|
xhr.upload.addEventListener('progress', (e: ProgressEvent) => {
|
||||||
|
if (e.lengthComputable && onProgress) {
|
||||||
|
const progress = Math.round((e.loaded / e.total) * 100);
|
||||||
|
onProgress(progress);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 上传成功
|
||||||
|
xhr.addEventListener('load', () => {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
resolve(fileId.value);
|
||||||
|
} else {
|
||||||
|
reject(new Error(`上传失败: ${xhr.status} ${xhr.statusText}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 网络错误
|
||||||
|
xhr.addEventListener('error', () => {
|
||||||
|
reject(new Error('网络错误,上传失败'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 上传取消
|
||||||
|
xhr.addEventListener('abort', () => {
|
||||||
|
reject(new Error('上传已取消'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 超时
|
||||||
|
xhr.addEventListener('timeout', () => {
|
||||||
|
reject(new Error('上传超时'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 支持外部取消
|
||||||
|
if (signal) {
|
||||||
|
signal.addEventListener('abort', () => {
|
||||||
|
xhr.abort();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始上传
|
||||||
|
xhr.open(method.value, uploadUrl.value, true);
|
||||||
|
for (const [key, value] of Object.entries(headers.value)) {
|
||||||
|
xhr.setRequestHeader(key, value);
|
||||||
|
}
|
||||||
|
xhr.timeout = 300000; // 5分钟超时
|
||||||
|
xhr.send(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -111,15 +111,14 @@ function handleSubmit() {
|
|||||||
<NInput v-model:value="form.description" type="textarea" />
|
<NInput v-model:value="form.description" type="textarea" />
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NFormItem path="proofDocuments" label="资产证明 ">
|
<NFormItem path="proofDocuments" label="资产证明 ">
|
||||||
<NUpload
|
<Upload
|
||||||
action="https://naive-upload.free.beeceptor.com/"
|
:model-value="form.proofDocuments?.split(',') || []"
|
||||||
:headers="{ 'naive-info': 'hello!' }"
|
:fetch-options="{
|
||||||
:data="{
|
businessType: 'rwa_proof'
|
||||||
'naive-data': 'cool! naive!'
|
|
||||||
}"
|
}"
|
||||||
>
|
accept="application/pdf,image/*,.doc,.docx"
|
||||||
<NButton>上传文件</NButton>
|
@update:model-value="val => (form.proofDocuments = val.join(','))"
|
||||||
</NUpload>
|
/>
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NSpace justify="end">
|
<NSpace justify="end">
|
||||||
<NButton @click="$emit('close')">取 消</NButton>
|
<NButton @click="$emit('close')">取 消</NButton>
|
||||||
|
|||||||
Reference in New Issue
Block a user