upload.ts 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081
  1. import { api } from '../api'
  2. const DEFAULT_COS_UPLOAD_HOST = 'https://ranking-1309648802.cos.ap-shanghai.myqcloud.com'
  3. const imageExtensions: Record<string, string> = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp' }
  4. const signatureFields = ['key', 'q-sign-algorithm', 'q-ak', 'q-key-time', 'q-signature', 'policy'] as const
  5. export function createUploadId(): string {
  6. if (typeof globalThis.crypto.randomUUID === 'function') return globalThis.crypto.randomUUID()
  7. const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16))
  8. bytes[6] = (bytes[6]! & 0x0f) | 0x40
  9. bytes[8] = (bytes[8]! & 0x3f) | 0x80
  10. const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
  11. return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
  12. }
  13. /**
  14. * 解析 COS 上传地址:优先使用凭证中的地址,其次使用环境变量,最后回退到默认地址。
  15. * 仅接受 HTTP/HTTPS 地址,返回其源地址(协议、域名和端口),不保留路径、查询参数及片段。
  16. * @param signature 后端返回的 COS 直传凭证。
  17. * @returns 用于直传和拼接文件 URL 的源地址。
  18. * @throws 地址无法解析或协议不受支持时抛出错误。
  19. */
  20. function uploadHost(signature: Record<string, unknown>) {
  21. const configured = import.meta.env.VITE_COS_UPLOAD_URL
  22. const returned = ['uploadUrl', 'cosUrl', 'url', 'host', 'domain', 'bucketUrl', 'fileUrl'].map(field => signature[field]).find(value => typeof value === 'string' && value.trim())
  23. const value = String(returned || configured || DEFAULT_COS_UPLOAD_HOST).trim()
  24. try {
  25. const url = new URL(value)
  26. if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error()
  27. return url.origin
  28. } catch {
  29. throw new Error('上传签名中的 COS 地址格式不正确')
  30. }
  31. }
  32. /**
  33. * 获取后端签名后,将非空 PNG、JPEG 或 WebP 图片通过表单直接上传至 COS。
  34. * @param file 待上传的图片文件,格式根据 MIME 类型校验。
  35. * @param options 上传配置;keyPrefix 为对象路径前缀,默认使用 wms/INBOUND。
  36. * @returns 上传成功后由 COS 源地址和签名 key 拼接的文件 URL,可用于业务附件提交。
  37. * @throws 文件校验、签名解析、必填字段校验或上传失败时抛出错误,由调用方处理。
  38. */
  39. export async function uploadImage(file: File, options: { keyPrefix?: string } = {}): Promise<string> {
  40. const extension = imageExtensions[file.type]
  41. if (!extension) throw new Error('仅支持 PNG、JPEG、WebP 图片')
  42. if (!file.size) throw new Error('不能上传空图片')
  43. // 按前缀、本地年月日和随机文件名申请签名,实际上传路径以后端返回的 key 为准。
  44. const prefix = (options.keyPrefix ?? 'wms/INBOUND').replace(/^\/+|\/+$/g, '')
  45. const now = new Date()
  46. const date = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`
  47. const filename = [prefix, date, `${createUploadId()}${extension}`].filter(Boolean).join('/')
  48. // 兼容接口直接返回凭证对象或返回 JSON 字符串两种格式。
  49. let params: unknown = await api.wmsApi.file.oss(filename)
  50. if (typeof params === 'string') {
  51. try { params = JSON.parse(params) }
  52. catch { throw new Error('上传签名格式不正确') }
  53. }
  54. if (!params || typeof params !== 'object' || Array.isArray(params)) throw new Error('上传签名格式不正确')
  55. const signature = params as Record<string, unknown>
  56. const cosUploadHost = uploadHost(signature)
  57. const form = new FormData()
  58. // 保留签名字段原值,并在使用临时凭证时附带安全令牌。
  59. for (const field of signatureFields) {
  60. const value = signature[field]
  61. if (typeof value !== 'string' || !value.trim()) throw new Error(`上传签名缺少有效字段:${field}`)
  62. form.append(field, value)
  63. }
  64. if (typeof signature['x-cos-security-token'] === 'string') {
  65. form.append('x-cos-security-token', signature['x-cos-security-token'])
  66. }
  67. form.append('success_action_status', '200')
  68. form.append('file', file, file.name)
  69. // COS 直传不携带 Cookie,表单的 Content-Type 和边界由浏览器自动生成。
  70. const response = await fetch(cosUploadHost, { method: 'POST', body: form, credentials: 'omit' })
  71. if (!response.ok) throw new Error(`图片上传失败,状态码:${response.status}`)
  72. // 逐段编码对象路径中的中文和空格,保留目录分隔符。
  73. const encodedKey = (signature.key as string).replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/')
  74. return `${cosUploadHost}/${encodedKey}`
  75. }