useImageUpload.ts 3.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
  1. /**
  2. * Composable for handling image uploads to S3
  3. */
  4. import { ref } from 'vue'
  5. import { showToast } from 'vant'
  6. import { uploadApi } from '~/api/upload'
  7. export interface UseImageUploadOptions {
  8. /**
  9. * Upload type (1=avatar, 2=identity card, etc.)
  10. */
  11. type: number
  12. /**
  13. * Max file size in MB
  14. */
  15. maxSizeMB?: number
  16. /**
  17. * Custom toast messages
  18. */
  19. messages?: {
  20. selectImage?: string
  21. onlyImage?: string
  22. tooLarge?: string
  23. signFailed?: string
  24. uploadUrlInvalid?: string
  25. uploadFailed?: string
  26. uploadSuccess?: string
  27. }
  28. }
  29. export function useImageUpload(options: UseImageUploadOptions) {
  30. const { t } = useI18n()
  31. const uploading = ref(false)
  32. const defaultMessages = {
  33. selectImage: t('editProfile.toast.selectImage'),
  34. onlyImage: t('editProfile.toast.onlyImage'),
  35. tooLarge: t('editProfile.toast.tooLarge'),
  36. signFailed: t('editProfile.toast.signFailed'),
  37. uploadUrlInvalid: t('editProfile.toast.uploadUrlInvalid'),
  38. uploadFailed: t('editProfile.toast.uploadFailed'),
  39. uploadSuccess: t('editProfile.toast.uploadSuccess'),
  40. }
  41. const messages = { ...defaultMessages, ...options.messages }
  42. const maxSizeMB = options.maxSizeMB || 5
  43. /**
  44. * Upload a file to S3 and return the file URL
  45. * @param file - The file to upload
  46. * @returns The uploaded file URL, or null if upload failed
  47. */
  48. const uploadFile = async (file: File): Promise<string | null> => {
  49. // Validate file type
  50. if (!file.type || !file.type.startsWith('image/')) {
  51. showToast(messages.onlyImage)
  52. return null
  53. }
  54. // Validate file size
  55. const isWithinSize = file.size / 1024 / 1024 < maxSizeMB
  56. if (!isWithinSize) {
  57. showToast(messages.tooLarge)
  58. return null
  59. }
  60. // Get file suffix
  61. const dotIndex = file.name.lastIndexOf('.')
  62. const suffix = dotIndex >= 0 ? file.name.slice(dotIndex + 1) : ''
  63. try {
  64. uploading.value = true
  65. // Get S3 pre-sign info
  66. const signResult = await uploadApi.getS3PreSign({
  67. type: options.type,
  68. suffix,
  69. })
  70. if (!signResult) {
  71. showToast(messages.signFailed)
  72. return null
  73. }
  74. const { preSignUrl, fileUrl } = signResult
  75. if (!preSignUrl) {
  76. showToast(messages.uploadUrlInvalid)
  77. return null
  78. }
  79. // Upload to S3 using native fetch PUT
  80. const res = await fetch(preSignUrl, {
  81. method: 'PUT',
  82. headers: {
  83. 'Content-Type': file.type || 'application/octet-stream',
  84. },
  85. body: file,
  86. })
  87. if (!res.ok) {
  88. console.error('S3 upload failed with status:', res.status)
  89. showToast(messages.uploadFailed)
  90. return null
  91. }
  92. // Return the file URL from backend, fallback to pre-signed URL without query params
  93. const uploadedUrl = fileUrl || preSignUrl.split('?')[0] || ''
  94. showToast(messages.uploadSuccess)
  95. return uploadedUrl
  96. }
  97. catch (error) {
  98. console.error('Upload file error:', error)
  99. showToast(messages.uploadFailed)
  100. return null
  101. }
  102. finally {
  103. uploading.value = false
  104. }
  105. }
  106. return {
  107. uploading,
  108. uploadFile,
  109. }
  110. }