From 8d964957e53c17b1b398ff33f6e9d1c6447b6790 Mon Sep 17 00:00:00 2001 From: Seven Date: Thu, 8 Jan 2026 21:46:59 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20S3=20=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=B8=8A=E4=BC=A0=E7=BB=84=E4=BB=B6=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=96=87=E4=BB=B6=E5=A4=A7=E5=B0=8F=E5=92=8C=E6=95=B0?= =?UTF-8?q?=E9=87=8F=E9=99=90=E5=88=B6=EF=BC=8C=E4=BC=98=E5=8C=96=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/upload/index.vue | 143 ++++++++++++++++++++++ src/typings/components.d.ts | 4 + src/utils/aws/s3.ts | 78 ++++++++++++ src/views/rwa/product/components/edit.vue | 15 ++- 4 files changed, 232 insertions(+), 8 deletions(-) create mode 100644 src/components/upload/index.vue create mode 100644 src/utils/aws/s3.ts diff --git a/src/components/upload/index.vue b/src/components/upload/index.vue new file mode 100644 index 0000000..0b23c6d --- /dev/null +++ b/src/components/upload/index.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/src/typings/components.d.ts b/src/typings/components.d.ts index 2d2dc9b..5585c7b 100644 --- a/src/typings/components.d.ts +++ b/src/typings/components.d.ts @@ -26,6 +26,7 @@ declare module 'vue' { IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default'] IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default'] IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default'] + IconIcRoundUpload: typeof import('~icons/ic/round-upload')['default'] IconLocalBanner: typeof import('~icons/local/banner')['default'] IconLocalLogo: typeof import('~icons/local/logo')['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'] TableHeaderOperation: typeof import('./../components/advanced/table-header-operation.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'] } } @@ -117,6 +119,7 @@ declare global { const IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default'] const IconIcRoundDelete: typeof import('~icons/ic/round-delete')['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 IconLocalLogo: typeof import('~icons/local/logo')['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 TableHeaderOperation: typeof import('./../components/advanced/table-header-operation.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'] } \ No newline at end of file diff --git a/src/utils/aws/s3.ts b/src/utils/aws/s3.ts new file mode 100644 index 0000000..6413fe1 --- /dev/null +++ b/src/utils/aws/s3.ts @@ -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; + +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((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); + }); +} diff --git a/src/views/rwa/product/components/edit.vue b/src/views/rwa/product/components/edit.vue index 4a7a973..37a3f1a 100644 --- a/src/views/rwa/product/components/edit.vue +++ b/src/views/rwa/product/components/edit.vue @@ -111,15 +111,14 @@ function handleSubmit() { - - 上传文件 - + accept="application/pdf,image/*,.doc,.docx" + @update:model-value="val => (form.proofDocuments = val.join(','))" + /> 取 消