diff --git a/apps/web-antd/src/api/core/upload.ts b/apps/web-antd/src/api/core/upload.ts index 7290f4f6..e447772d 100644 --- a/apps/web-antd/src/api/core/upload.ts +++ b/apps/web-antd/src/api/core/upload.ts @@ -2,22 +2,67 @@ import { requestClient } from '#/api/request'; /** * 文件上传 - * @param data + * 通过后端服务器上传文件到OSS + * + * @param data 上传数据,包含file字段(文件对象) + * @returns Promise 返回上传结果,包含url字段(文件URL) */ export async function uploadFile(data: any) { return requestClient.upload('upload/image', data); } + /** * 文件上传 - 聊天信息 - * @param data + * 用于聊天场景的文件上传 + * + * @param data 上传数据,包含file字段(文件对象) + * @returns Promise 返回上传结果,包含url字段(文件URL) */ export async function uploadChatFile(data: any) { return requestClient.upload('upload/chat-file', data); } +/** + * 获取OSS上传签名信息 + * + * 该接口用于获取OSS直传所需的签名信息,包括: + * - accessKeyId: 阿里云访问密钥ID + * - policy: Base64编码的上传策略 + * - signature: 签名值 + * - host: OSS服务端点URL + * - bucket: 存储桶名称(从后端环境变量获取,返回给前端) + * - key: 文件路径前缀 + * - expire: 签名过期时间戳 + * + * 使用场景: + * - 前端需要直接上传文件到OSS时,先调用此接口获取签名 + * - 签名具有时效性,需要在有效期内使用 + * - 每次上传前建议重新获取签名,确保签名未过期 + * + * 错误处理: + * - 如果后端返回错误,会通过requestClient的统一错误处理机制处理 + * - 调用方需要捕获异常并进行相应处理 + * + * @returns Promise 返回OSS签名信息对象 + * @throws 如果API调用失败,会抛出错误 + * + * @example + * try { + * const signature = await getOssSignature(); + * console.log('获取签名成功', signature); + * } catch (error) { + * console.error('获取签名失败', error); + * } + */ +export async function getOssSignature() { + return requestClient.get('/upload/oss-signature'); +} + /** * 通过文件id集合获取文件信息 - * @param data + * + * @param data 请求数据,包含fileInfoIds字段(文件ID数组或逗号分隔的字符串) + * @returns Promise 返回文件信息数组 */ export async function getFileInfoByIds(data: any) { return requestClient.post('/sys/fileInfo/getFileInfoByIds', data); diff --git a/apps/web-antd/src/components/form/components/avatar.vue b/apps/web-antd/src/components/form/components/avatar.vue index 7be437cf..0296d042 100644 --- a/apps/web-antd/src/components/form/components/avatar.vue +++ b/apps/web-antd/src/components/form/components/avatar.vue @@ -2,7 +2,10 @@ import { useVModel } from '@vueuse/core'; import { Upload } from 'ant-design-vue'; +// 导入上传相关API和工具函数 import { uploadFile } from '#/api/core/upload'; +import { uploadToOss } from '#/utils/oss-upload'; +import { preferences } from '@vben/preferences'; import { Icon } from '#/components/icon'; defineOptions({ @@ -19,12 +22,59 @@ const mValue = useVModel(props, 'value', emits, { defaultValue: props.value, passive: true, }); -const customRequest = (e: any) => { - uploadFile({ - file: e.file, - }).then((data: any) => { +/** + * 自定义上传请求处理函数 + * + * 该函数根据preferences配置的上传方式,选择使用OSS直传或后端上传: + * - 'direct': OSS直传模式,文件直接从浏览器上传到阿里云OSS + * - 'backend': 后端上传模式,文件先上传到后端服务器,再由后端上传到OSS + * + * 上传方式说明: + * 1. OSS直传(direct): + * - 调用uploadToOss函数,直接上传到OSS + * - 减少服务器负载,上传速度更快 + * + * 2. 后端上传(backend): + * - 调用uploadFile API,通过后端服务器上传 + * - 兼容现有功能,所有上传逻辑由后端统一处理 + * + * 兼容性处理: + * - 两种上传方式返回的数据结构保持一致:{ url: string } + * - 上传成功后,将URL赋值给mValue,触发组件更新 + * + * @param e 上传事件对象,包含file字段(文件对象) + */ +const customRequest = async (e: any) => { + try { + // 从preferences中读取上传方式配置 + // uploadMethod可能的值:'direct'(OSS直传)或 'backend'(后端上传) + const uploadMethod = preferences.app.uploadMethod || 'direct'; + + let data: { url: string }; + + // 根据配置选择上传方式 + if (uploadMethod === 'direct') { + // OSS直传模式:文件直接从浏览器上传到OSS + // uploadToOss函数会处理签名获取、文件上传等所有逻辑 + data = await uploadToOss({ + file: e.file as File, + }); + } else { + // 后端上传模式:文件先上传到后端服务器,再由后端上传到OSS + // uploadFile函数会将文件发送到后端API(/upload/image) + data = await uploadFile({ + file: e.file, + }); + } + + // 上传成功,将URL赋值给mValue + // mValue是双向绑定的值,更新后会触发父组件的更新 mValue.value = data.url; - }); + } catch (error) { + // 上传失败,记录错误信息 + // 注意:这里没有显示错误提示,如果需要可以添加message.error + console.error('头像上传失败', error); + } }; const handleRemove = (e: Event) => { e.stopPropagation(); diff --git a/apps/web-antd/src/components/form/components/editor.vue b/apps/web-antd/src/components/form/components/editor.vue index 21dddf20..25a06673 100644 --- a/apps/web-antd/src/components/form/components/editor.vue +++ b/apps/web-antd/src/components/form/components/editor.vue @@ -7,7 +7,10 @@ import '@vueup/vue-quill/dist/vue-quill.snow.css'; import { useVModel } from '@vueuse/core'; import { message } from 'ant-design-vue'; +// 导入上传相关API和工具函数 import { uploadFile } from '#/api/core/upload'; +import { uploadToOss } from '#/utils/oss-upload'; +import { preferences } from '@vben/preferences'; const props = defineProps({ value: { @@ -136,33 +139,82 @@ const insertImageToEditor = (quill: any, imageUrl: string): void => { }; /** * 图片上传函数 + * + * 该函数根据preferences配置的上传方式,选择使用OSS直传或后端上传: + * - 'direct': OSS直传模式,文件直接从浏览器上传到阿里云OSS + * - 'backend': 后端上传模式,文件先上传到后端服务器,再由后端上传到OSS + * + * 上传方式说明: + * 1. OSS直传(direct): + * - 调用uploadToOss函数,直接上传到OSS + * - 减少服务器负载,上传速度更快 + * - 支持上传进度回调(如果需要可以添加) + * + * 2. 后端上传(backend): + * - 调用uploadFile API,通过后端服务器上传 + * - 兼容现有功能,所有上传逻辑由后端统一处理 + * + * 兼容性处理: + * - 两种上传方式返回的数据结构保持一致:{ url: string } + * - 上传成功或失败都会显示相应的提示信息 + * - 上传完成后,将isUpload标记设置为false,允许下次上传 + * * @param {File} file - 要上传的图片文件 - * @returns {Promise} - 返回上传后的图片URL + * @returns {Promise} - 返回上传后的图片URL,如果上传失败返回空字符串 */ const uploadImage = async (file: File): Promise => { try { - // 创建FormData对象,用于发送文件数据 - const formData = new FormData(); - formData.append('file', file); - // 显示上传中的提示 message.loading({ content: '图片上传中...', key: 'imageUpload' }); - return uploadFile({ - file, - }).then((data: any) => { - message.success({ - content: '图片上传成功', - key: 'imageUpload', - duration: 2, + // 从preferences中读取上传方式配置 + // uploadMethod可能的值:'direct'(OSS直传)或 'backend'(后端上传) + const uploadMethod = preferences.app.uploadMethod || 'direct'; + + let data: { url: string }; + + // 根据配置选择上传方式 + if (uploadMethod === 'direct') { + // OSS直传模式:文件直接从浏览器上传到OSS + // uploadToOss函数会处理签名获取、文件上传等所有逻辑 + data = await uploadToOss({ + file: file, + // 如果需要显示上传进度,可以添加onProgress回调 + // onProgress: (percent) => { + // message.loading({ content: `图片上传中... ${percent}%`, key: 'imageUpload' }); + // }, }); - isUpload.value = false; - return data.url; + } else { + // 后端上传模式:文件先上传到后端服务器,再由后端上传到OSS + // uploadFile函数会将文件发送到后端API(/upload/image) + data = await uploadFile({ + file, + }); + } + + // 上传成功,显示成功提示 + message.success({ + content: '图片上传成功', + key: 'imageUpload', + duration: 2, }); + + // 重置上传标记,允许下次上传 + isUpload.value = false; + + // 返回上传后的图片URL + return data.url; } catch (error) { // 处理上传错误 console.error('图片上传错误:', error); + + // 显示错误提示 message.error({ content: '图片上传失败', key: 'imageUpload', duration: 2 }); + + // 重置上传标记,允许重试 + isUpload.value = false; + + // 返回空字符串,表示上传失败 return ''; } }; diff --git a/apps/web-antd/src/components/form/components/upload-image-sortable.vue b/apps/web-antd/src/components/form/components/upload-image-sortable.vue index 6922f3f1..421c17ea 100644 --- a/apps/web-antd/src/components/form/components/upload-image-sortable.vue +++ b/apps/web-antd/src/components/form/components/upload-image-sortable.vue @@ -4,7 +4,10 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue'; import { Modal, Upload } from 'ant-design-vue'; import type { UploadFile } from 'ant-design-vue'; +// 导入上传相关API和工具函数 import { uploadFile } from '#/api/core/upload'; +import { uploadToOss } from '#/utils/oss-upload'; +import { preferences } from '@vben/preferences'; import { Icon } from '#/components/icon'; defineOptions({ @@ -132,11 +135,36 @@ const initSortable = () => { }); }; -// 使用原有的uploadFile接口(与upload-image.vue保持一致) +/** + * 自定义上传请求处理函数 + * + * 该函数根据preferences配置的上传方式,选择使用OSS直传或后端上传: + * - 'direct': OSS直传模式,文件直接从浏览器上传到阿里云OSS + * - 'backend': 后端上传模式,文件先上传到后端服务器,再由后端上传到OSS + * + * 两种上传方式的区别: + * 1. OSS直传(direct): + * - 优点:减少服务器负载,上传速度更快,用户体验更好 + * - 缺点:需要后端提供签名接口,配置相对复杂 + * - 实现:调用uploadToOss函数,直接上传到OSS + * + * 2. 后端上传(backend): + * - 优点:兼容现有功能,所有上传逻辑由后端统一处理 + * - 缺点:增加服务器负载,上传速度相对较慢 + * - 实现:调用uploadFile API,通过后端服务器上传 + * + * 兼容性处理: + * - 两种上传方式返回的数据结构保持一致:{ url: string } + * - 确保无论使用哪种方式,组件的行为都是一致的 + * - 上传成功后,会重新初始化拖拽排序功能 + * - 如果上传失败,会更新文件状态为error,并调用onError回调 + * + * @param options 上传选项,包含file、onProgress、onSuccess、onError等 + */ const customRequest = async (options: any) => { const { file, onProgress, onSuccess, onError } = options; - // 创建上传中的文件项 + // 创建上传中的文件项,用于在UI中显示上传状态 const uploadingFile: FileItem = { uid: file.uid, name: file.name, @@ -144,15 +172,48 @@ const customRequest = async (options: any) => { url: '', }; + // 将上传中的文件添加到文件列表 fileList.value = [...fileList.value, uploadingFile]; try { - // 使用原有的uploadFile接口 - const res = await uploadFile({ - file: file, - }); + // 从preferences中读取上传方式配置 + // uploadMethod可能的值:'direct'(OSS直传)或 'backend'(后端上传) + const uploadMethod = preferences.app.uploadMethod || 'direct'; - // 更新文件状态为完成 + let res: { url: string }; + + // 根据配置选择上传方式 + if (uploadMethod === 'direct') { + // OSS直传模式:文件直接从浏览器上传到OSS + // uploadToOss函数会: + // 1. 从后端获取OSS签名信息 + // 2. 生成全局唯一的文件名(UUID + 时间戳) + // 3. 使用FormData构造POST请求直接上传到OSS + // 4. 处理上传进度和错误 + // 5. 返回上传后的文件URL + res = await uploadToOss({ + file: file as File, + onProgress: (percent) => { + // 将OSS上传进度传递给组件 + // percent范围:0-100,表示上传百分比 + if (onProgress) { + onProgress(percent); + } + }, + }); + } else { + // 后端上传模式:文件先上传到后端服务器,再由后端上传到OSS + // uploadFile函数会: + // 1. 将文件发送到后端API(/upload/image) + // 2. 后端接收文件后上传到OSS + // 3. 返回上传后的文件URL + res = await uploadFile({ + file: file, + }); + } + + // 上传成功,更新文件状态为完成 + // 将上传结果中的URL赋值给文件项 const updatedFileList = fileList.value.map(item => item.uid === file.uid ? { ...item, status: 'done' as const, url: res.url } @@ -160,17 +221,24 @@ const customRequest = async (options: any) => { ); fileList.value = updatedFileList; + + // 更新组件的modelValue,触发父组件的更新 updateModelValue(); - // 重新初始化排序 + // 重新初始化拖拽排序功能 + // 因为文件列表发生了变化,需要重新绑定拖拽事件 nextTick(() => { initSortable(); }); + // 调用成功回调,通知上传组件上传已完成 onSuccess(res); } catch (error) { + // 上传失败,记录错误信息 console.error('上传失败', error); + // 更新文件状态为错误 + // 错误状态的文件会在UI中显示错误图标 const updatedFileList = fileList.value.map(item => item.uid === file.uid ? { ...item, status: 'error' as const } @@ -178,6 +246,8 @@ const customRequest = async (options: any) => { ); fileList.value = updatedFileList; + + // 调用错误回调,通知上传组件上传失败 onError(error); } }; diff --git a/apps/web-antd/src/components/form/components/upload-image.vue b/apps/web-antd/src/components/form/components/upload-image.vue index 04a18d0a..3e3ea8e7 100644 --- a/apps/web-antd/src/components/form/components/upload-image.vue +++ b/apps/web-antd/src/components/form/components/upload-image.vue @@ -3,8 +3,10 @@ import { computed, ref, watch } from 'vue'; import { Modal, Upload } from 'ant-design-vue'; import type { UploadFile } from 'ant-design-vue'; -// 恢复使用原有的上传接口 +// 导入上传相关API和工具函数 import { uploadFile } from '#/api/core/upload'; +import { uploadToOss } from '#/utils/oss-upload'; +import { preferences } from '@vben/preferences'; import { Icon } from '#/components/icon'; // 定义接口 @@ -68,11 +70,35 @@ const updateModelValue = () => { emit('update:modelValue', urls); }; -// 使用原有的uploadFile接口 +/** + * 自定义上传请求处理函数 + * + * 该函数根据preferences配置的上传方式,选择使用OSS直传或后端上传: + * - 'direct': OSS直传模式,文件直接从浏览器上传到阿里云OSS + * - 'backend': 后端上传模式,文件先上传到后端服务器,再由后端上传到OSS + * + * 两种上传方式的区别: + * 1. OSS直传(direct): + * - 优点:减少服务器负载,上传速度更快,用户体验更好 + * - 缺点:需要后端提供签名接口,配置相对复杂 + * - 实现:调用uploadToOss函数,直接上传到OSS + * + * 2. 后端上传(backend): + * - 优点:兼容现有功能,所有上传逻辑由后端统一处理 + * - 缺点:增加服务器负载,上传速度相对较慢 + * - 实现:调用uploadFile API,通过后端服务器上传 + * + * 兼容性处理: + * - 两种上传方式返回的数据结构保持一致:{ url: string } + * - 确保无论使用哪种方式,组件的行为都是一致的 + * - 如果上传失败,会更新文件状态为error,并调用onError回调 + * + * @param options 上传选项,包含file、onProgress、onSuccess、onError等 + */ const customRequest = async (options: any) => { const { file, onProgress, onSuccess, onError } = options; - // 创建上传中的文件项 + // 创建上传中的文件项,用于在UI中显示上传状态 const uploadingFile: FileItem = { uid: file.uid, name: file.name, @@ -80,15 +106,48 @@ const customRequest = async (options: any) => { url: '', }; + // 将上传中的文件添加到文件列表 fileList.value = [...fileList.value, uploadingFile]; try { - // 使用原有的uploadFile接口 - const res = await uploadFile({ - file: file, - }); + // 从preferences中读取上传方式配置 + // uploadMethod可能的值:'direct'(OSS直传)或 'backend'(后端上传) + const uploadMethod = preferences.app.uploadMethod || 'direct'; - // 更新文件状态为完成 + let res: { url: string }; + + // 根据配置选择上传方式 + if (uploadMethod === 'direct') { + // OSS直传模式:文件直接从浏览器上传到OSS + // uploadToOss函数会: + // 1. 从后端获取OSS签名信息 + // 2. 生成全局唯一的文件名(UUID + 时间戳) + // 3. 使用FormData构造POST请求直接上传到OSS + // 4. 处理上传进度和错误 + // 5. 返回上传后的文件URL + res = await uploadToOss({ + file: file as File, + onProgress: (percent) => { + // 将OSS上传进度传递给组件 + // percent范围:0-100,表示上传百分比 + if (onProgress) { + onProgress(percent); + } + }, + }); + } else { + // 后端上传模式:文件先上传到后端服务器,再由后端上传到OSS + // uploadFile函数会: + // 1. 将文件发送到后端API(/upload/image) + // 2. 后端接收文件后上传到OSS + // 3. 返回上传后的文件URL + res = await uploadFile({ + file: file, + }); + } + + // 上传成功,更新文件状态为完成 + // 将上传结果中的URL赋值给文件项 const updatedFileList = fileList.value.map(item => item.uid === file.uid ? { ...item, status: 'done' as const, url: res.url } @@ -96,11 +155,18 @@ const customRequest = async (options: any) => { ); fileList.value = updatedFileList; + + // 更新组件的modelValue,触发父组件的更新 updateModelValue(); + + // 调用成功回调,通知上传组件上传已完成 onSuccess(res); } catch (error) { + // 上传失败,记录错误信息 console.error('上传失败', error); + // 更新文件状态为错误 + // 错误状态的文件会在UI中显示错误图标 const updatedFileList = fileList.value.map(item => item.uid === file.uid ? { ...item, status: 'error' as const } @@ -108,6 +174,8 @@ const customRequest = async (options: any) => { ); fileList.value = updatedFileList; + + // 调用错误回调,通知上传组件上传失败 onError(error); } }; diff --git a/apps/web-antd/src/preferences.ts b/apps/web-antd/src/preferences.ts index eb7309f6..73b89278 100644 --- a/apps/web-antd/src/preferences.ts +++ b/apps/web-antd/src/preferences.ts @@ -34,6 +34,24 @@ export const overridesPreferences = defineOverridesPreferences({ // 检查更新的时间间隔,单位为分钟 checkUpdatesInterval: 1, version: '1.0.0', + /** + * 文件上传方式配置 + * + * 可选值: + * - 'direct': OSS直传模式,文件直接从浏览器上传到阿里云OSS(推荐,性能更好) + * - 'backend': 后端上传模式,文件先上传到后端服务器,再由后端上传到OSS(兼容模式) + * + * 默认值:'direct'(OSS直传) + * + * 说明: + * - OSS直传模式:减少服务器负载,上传速度更快,但需要后端提供签名接口 + * - 后端上传模式:兼容现有功能,所有上传逻辑由后端处理 + * + * 切换方式: + * - 可以通过修改此配置值来切换上传方式 + * - 配置会持久化到本地存储,刷新页面后仍然有效 + */ + uploadMethod: 'direct' as 'direct' | 'backend', }, sidebar: { extraCollapse: false, diff --git a/apps/web-antd/src/utils/oss-upload.ts b/apps/web-antd/src/utils/oss-upload.ts new file mode 100644 index 00000000..ae4eab5c --- /dev/null +++ b/apps/web-antd/src/utils/oss-upload.ts @@ -0,0 +1,345 @@ +/** + * OSS直传工具函数 + * + * 该模块实现了阿里云OSS的Web直传功能,支持客户端直接上传文件到OSS存储桶 + * 参考文档:https://help.aliyun.com/zh/oss/user-guide/obtain-signature-information-from-the-server-and-upload-data-to-oss + * + * 主要功能: + * 1. 从后端获取OSS上传签名信息 + * 2. 生成全局唯一的文件名(UUID + 时间戳) + * 3. 使用FormData构造POST请求直接上传到OSS + * 4. 处理上传进度和错误 + * 5. 返回上传后的文件URL + */ + +import { getOssSignature } from '#/api/core/upload'; + +/** + * OSS签名信息接口 + * 后端返回的签名数据结构 + */ +export interface OssSignature { + /** 阿里云AccessKeyId */ + accessKeyId: string; + /** Base64编码的PostPolicy策略 */ + policy: string; + /** 签名值 */ + signature: string; + /** OSS服务端点URL */ + host: string; + /** 存储桶名称(从后端返回,不能硬编码) */ + bucket: string; + /** 文件路径前缀(仅作为路径前缀,完整文件名由前端生成) */ + key: string; + /** 签名过期时间戳(Unix时间戳,秒级) */ + expire: number; +} + +/** + * 上传选项接口 + */ +export interface UploadOptions { + /** 要上传的文件对象 */ + file: File; + /** 上传进度回调函数,参数为0-100的进度百分比 */ + onProgress?: (percent: number) => void; +} + +/** + * 上传结果接口 + */ +export interface UploadResult { + /** 上传后的文件完整URL */ + url: string; + /** 文件在OSS中的objectName(相对路径) */ + objectName: string; +} + +/** + * 生成全局唯一的文件名 + * + * 文件名格式:xk_upload_日期/UUID_时间戳.扩展名 + * 例如:xk_upload_20250108/550e8400-e29b-41d4-a716-446655440000_1736323200.jpg + * + * 规则说明: + * - 基础路径:xk_upload_日期/ (日期格式:YYYYMMDD) + * - 文件名:UUID_时间戳.扩展名 + * - UUID:使用浏览器原生crypto.randomUUID()生成,确保全局唯一 + * - 时间戳:使用Unix时间戳(秒级),通过Math.floor(Date.now() / 1000)获取 + * - 扩展名:从原始文件名提取 + * + * @param file 文件对象,用于获取文件名和扩展名 + * @returns 返回完整的objectName路径 + * + * @example + * const objectName = generateObjectName(file); + * // 返回: "xk_upload_20250108/550e8400-e29b-41d4-a716-446655440000_1736323200.jpg" + */ +export function generateObjectName(file: File): string { + // 获取当前日期,格式:YYYYMMDD + const today = new Date(); + const year = today.getFullYear(); + const month = String(today.getMonth() + 1).padStart(2, '0'); + const day = String(today.getDate()).padStart(2, '0'); + const dateStr = `${year}${month}${day}`; + + // 生成UUID,使用浏览器原生API确保全局唯一 + // crypto.randomUUID() 是Web Crypto API的一部分,支持现代浏览器 + let uuid: string; + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + uuid = crypto.randomUUID(); + } else { + // 降级方案:如果浏览器不支持randomUUID,使用替代方案 + // 生成类似UUID格式的随机字符串 + uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); + } + + // 获取Unix时间戳(秒级) + // Date.now()返回毫秒级时间戳,除以1000并向下取整得到秒级时间戳 + const timestamp = Math.floor(Date.now() / 1000); + + // 获取文件扩展名 + // 从文件名中提取扩展名,如果没有扩展名则使用空字符串 + const fileName = file.name; + const lastDotIndex = fileName.lastIndexOf('.'); + const extension = lastDotIndex > 0 ? fileName.substring(lastDotIndex + 1).toLowerCase() : ''; + + // 组合成完整的objectName路径 + // 格式:xk_upload_日期/UUID_时间戳.扩展名 + const objectName = `xk_upload_${dateStr}/${uuid}_${timestamp}${extension ? '.' + extension : ''}`; + + return objectName; +} + +/** + * 从后端获取OSS上传签名信息 + * + * 该函数调用后端API获取OSS上传所需的签名信息,包括: + * - accessKeyId: 阿里云访问密钥ID + * - policy: Base64编码的上传策略 + * - signature: 签名值 + * - host: OSS服务端点 + * - bucket: 存储桶名称(从后端返回,确保配置灵活性) + * - key: 文件路径前缀 + * - expire: 签名过期时间 + * + * 错误处理: + * - 如果API调用失败,会抛出错误 + * - 调用方需要捕获错误并进行相应处理 + * + * @returns Promise 返回签名信息对象 + * @throws 如果API调用失败或返回数据格式不正确,会抛出错误 + * + * @example + * try { + * const signature = await getOssSignature(); + * console.log('获取签名成功', signature); + * } catch (error) { + * console.error('获取签名失败', error); + * } + */ +export async function getOssSignatureFromBackend(): Promise { + try { + // 调用后端API获取签名信息 + const response = await getOssSignature(); + + // 验证返回数据的完整性 + if (!response || !response.accessKeyId || !response.policy || !response.signature) { + throw new Error('后端返回的签名信息不完整'); + } + + // 确保bucket字段存在(重要:bucket必须从后端返回) + if (!response.bucket) { + throw new Error('后端未返回bucket信息,无法进行上传'); + } + + return response as OssSignature; + } catch (error) { + // 记录错误信息,便于调试 + console.error('获取OSS签名失败:', error); + throw error; + } +} + +/** + * 上传文件到OSS + * + * 该函数实现了完整的OSS直传流程: + * 1. 从后端获取签名信息 + * 2. 生成全局唯一的文件名 + * 3. 构造FormData并设置必要的字段 + * 4. 使用XMLHttpRequest发送POST请求到OSS + * 5. 处理上传进度和错误 + * 6. 返回上传后的文件URL + * + * 上传流程说明: + * - 使用FormData构造multipart/form-data格式的请求 + * - 必须包含的字段:key, policy, OSSAccessKeyId, signature, file + * - key字段使用生成的objectName + * - 上传到OSS的host端点 + * - 上传成功后,文件URL格式:host/objectName + * + * 进度处理: + * - 使用XMLHttpRequest的upload.onprogress事件监听上传进度 + * - 进度值范围:0-100,表示上传百分比 + * - 通过onProgress回调函数通知调用方 + * + * 错误处理: + * - 网络错误:捕获XMLHttpRequest的错误事件 + * - OSS错误:解析OSS返回的XML错误响应 + * - 签名过期:检查签名是否在有效期内 + * + * @param options 上传选项,包含文件和进度回调 + * @returns Promise 返回上传结果,包含文件URL和objectName + * @throws 如果上传失败,会抛出包含错误信息的异常 + * + * @example + * try { + * const result = await uploadToOss({ + * file: fileObject, + * onProgress: (percent) => { + * console.log(`上传进度: ${percent}%`); + * } + * }); + * console.log('上传成功,文件URL:', result.url); + * } catch (error) { + * console.error('上传失败', error); + * } + */ +export async function uploadToOss(options: UploadOptions): Promise { + const { file, onProgress } = options; + + // 步骤1:从后端获取OSS签名信息 + // 签名信息包含上传所需的所有认证参数 + const signature = await getOssSignatureFromBackend(); + + // 步骤2:生成全局唯一的文件名 + // 使用UUID + 时间戳确保文件名全局唯一,避免冲突 + const objectName = generateObjectName(file); + + // 步骤3:构造FormData,设置OSS上传所需的字段 + // FormData用于构造multipart/form-data格式的请求体 + const formData = new FormData(); + + // key: 文件在OSS中的完整路径(objectName) + // 这是OSS中文件的唯一标识 + formData.append('key', objectName); + + // policy: Base64编码的上传策略 + // 策略定义了上传的条件,如文件大小限制、允许的文件类型等 + formData.append('policy', signature.policy); + + // OSSAccessKeyId: 阿里云访问密钥ID + // 用于标识上传请求的发起者 + formData.append('OSSAccessKeyId', signature.accessKeyId); + + // signature: 签名值 + // 使用AccessKeySecret对policy进行签名,确保策略未被篡改 + formData.append('signature', signature.signature); + + // success_action_status: 指定上传成功后OSS返回的HTTP状态码 + // 默认是204(无内容),设置为200后OSS返回200状态码 + // 这样前端可以通过200状态码明确判断上传成功 + // 注意:此字段必须与后端Policy中的条件一致,否则会上传失败 + formData.append('success_action_status', '200'); + + // x-oss-object-acl: 指定文件的访问权限(ACL) + // 设置为 public-read 表示文件可以被公开访问,无需签名URL + // 这与后端上传保持一致(后端上传后会调用putObjectAcl设置公共读) + // 注意:此字段必须与后端Policy中的条件一致,否则会上传失败 + formData.append('x-oss-object-acl', 'public-read'); + + // file: 要上传的文件 + // 必须是最后一个字段,OSS要求file字段在FormData的最后 + formData.append('file', file); + + // 步骤4:使用XMLHttpRequest发送POST请求到OSS + // 使用XMLHttpRequest而不是fetch,因为需要监听上传进度 + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + // 监听上传进度 + // upload.onprogress事件在上传过程中会多次触发 + if (onProgress && xhr.upload) { + xhr.upload.addEventListener('progress', (event) => { + if (event.lengthComputable) { + // 计算上传进度百分比 + // loaded: 已上传的字节数 + // total: 文件总字节数 + const percent = Math.round((event.loaded / event.total) * 100); + onProgress(percent); + } + }); + } + + // 监听请求完成事件 + xhr.addEventListener('load', () => { + // 检查HTTP状态码 + // 200-299表示成功,204表示成功但无内容 + if (xhr.status >= 200 && xhr.status < 300 || xhr.status === 204) { + // 上传成功,构造文件URL + // URL格式:host/objectName + // 注意:使用后端返回的host,确保配置的灵活性 + const fileUrl = `${signature.host}/${objectName}`; + + resolve({ + url: fileUrl, + objectName: objectName, + }); + } else { + // HTTP状态码表示错误,尝试解析OSS返回的错误信息 + let errorMessage = `上传失败,HTTP状态码: ${xhr.status}`; + + try { + // OSS错误响应通常是XML格式 + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(xhr.responseText, 'text/xml'); + const errorNode = xmlDoc.querySelector('Error'); + + if (errorNode) { + const codeNode = errorNode.querySelector('Code'); + const messageNode = errorNode.querySelector('Message'); + + if (codeNode && messageNode) { + errorMessage = `OSS错误 [${codeNode.textContent}]: ${messageNode.textContent}`; + } + } + } catch (parseError) { + // 如果解析失败,使用默认错误信息 + console.warn('解析OSS错误响应失败:', parseError); + } + + reject(new Error(errorMessage)); + } + }); + + // 监听网络错误事件 + // 当网络请求失败时触发(如网络断开、超时等) + xhr.addEventListener('error', () => { + reject(new Error('网络错误:无法连接到OSS服务器')); + }); + + // 监听请求超时事件 + // 如果请求时间超过timeout设置的时间,会触发此事件 + xhr.addEventListener('timeout', () => { + reject(new Error('上传超时:请求时间过长')); + }); + + // 设置请求超时时间(30秒) + // 对于大文件,可能需要增加超时时间 + xhr.timeout = 30000; + + // 打开POST请求 + // 使用后端返回的host作为请求URL + xhr.open('POST', signature.host, true); + + // 发送请求 + // 将FormData作为请求体发送 + xhr.send(formData); + }); +} +