2
0

minio.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. import type { InferResponseType } from 'hono/client';
  2. import { fileClient } from "../api";
  3. export interface MinioProgressEvent {
  4. stage: 'uploading' | 'complete' | 'error';
  5. message: string;
  6. progress: number;
  7. details?: {
  8. loaded: number;
  9. total: number;
  10. };
  11. timestamp: number;
  12. }
  13. export interface MinioProgressCallbacks {
  14. onProgress?: (event: MinioProgressEvent) => void;
  15. onComplete?: () => void;
  16. onError?: (error: Error) => void;
  17. signal?: AbortSignal;
  18. }
  19. export interface UploadResult {
  20. fileUrl:string;
  21. fileKey:string;
  22. bucketName:string;
  23. }
  24. interface UploadPart {
  25. ETag: string;
  26. PartNumber: number;
  27. }
  28. interface UploadProgressDetails {
  29. partNumber: number;
  30. totalParts: number;
  31. partSize: number;
  32. totalSize: number;
  33. partProgress?: number;
  34. }
  35. type MinioMultipartUploadPolicy = InferResponseType<typeof fileClient["multipart-policy"]['$post'],200>
  36. type MinioUploadPolicy = InferResponseType<typeof fileClient["upload-policy"]['$post'],200>
  37. const PART_SIZE = 5 * 1024 * 1024; // 每部分5MB
  38. export class MinIOXHRMultipartUploader {
  39. /**
  40. * 使用XHR分段上传文件到MinIO
  41. */
  42. static async upload(
  43. policy: MinioMultipartUploadPolicy,
  44. file: File | Blob,
  45. key: string,
  46. callbacks?: MinioProgressCallbacks
  47. ): Promise<UploadResult> {
  48. const partSize = PART_SIZE;
  49. const totalSize = file.size;
  50. const totalParts = Math.ceil(totalSize / partSize);
  51. const uploadedParts: UploadPart[] = [];
  52. callbacks?.onProgress?.({
  53. stage: 'uploading',
  54. message: '准备上传文件...',
  55. progress: 0,
  56. details: {
  57. loaded: 0,
  58. total: totalSize
  59. },
  60. timestamp: Date.now()
  61. });
  62. // 分段上传
  63. for (let i = 0; i < totalParts; i++) {
  64. const start = i * partSize;
  65. const end = Math.min(start + partSize, totalSize);
  66. const partBlob = file.slice(start, end);
  67. const partNumber = i + 1;
  68. try {
  69. const etag = await this.uploadPart(
  70. policy.partUrls[i],
  71. partBlob,
  72. callbacks,
  73. {
  74. partNumber,
  75. totalParts,
  76. partSize: partBlob.size,
  77. totalSize
  78. }
  79. );
  80. uploadedParts.push({
  81. ETag: etag,
  82. PartNumber: partNumber
  83. });
  84. // 更新进度
  85. const progress = Math.round((end / totalSize) * 100);
  86. callbacks?.onProgress?.({
  87. stage: 'uploading',
  88. message: `上传文件片段 ${partNumber}/${totalParts}`,
  89. progress,
  90. details: {
  91. loaded: end,
  92. total: totalSize,
  93. },
  94. timestamp: Date.now()
  95. });
  96. } catch (error) {
  97. callbacks?.onError?.(error instanceof Error ? error : new Error(String(error)));
  98. throw error;
  99. }
  100. }
  101. // 完成上传
  102. try {
  103. await this.completeMultipartUpload(policy, key, uploadedParts);
  104. callbacks?.onProgress?.({
  105. stage: 'complete',
  106. message: '文件上传完成',
  107. progress: 100,
  108. timestamp: Date.now()
  109. });
  110. callbacks?.onComplete?.();
  111. return {
  112. fileUrl: `${policy.host}/${key}`,
  113. fileKey: key,
  114. bucketName: policy.bucket
  115. };
  116. } catch (error) {
  117. callbacks?.onError?.(error instanceof Error ? error : new Error(String(error)));
  118. throw error;
  119. }
  120. }
  121. // 上传单个片段
  122. private static uploadPart(
  123. uploadUrl: string,
  124. partBlob: Blob,
  125. callbacks?: MinioProgressCallbacks,
  126. progressDetails?: UploadProgressDetails
  127. ): Promise<string> {
  128. return new Promise((resolve, reject) => {
  129. const xhr = new XMLHttpRequest();
  130. xhr.upload.onprogress = (event) => {
  131. if (event.lengthComputable && callbacks?.onProgress) {
  132. const partProgress = Math.round((event.loaded / event.total) * 100);
  133. callbacks.onProgress({
  134. stage: 'uploading',
  135. message: `上传文件片段 ${progressDetails?.partNumber}/${progressDetails?.totalParts} (${partProgress}%)`,
  136. progress: Math.round((
  137. (progressDetails?.partNumber ? (progressDetails.partNumber - 1) * (progressDetails.partSize || 0) : 0) + event.loaded
  138. ) / (progressDetails?.totalSize || 1) * 100),
  139. details: {
  140. ...progressDetails,
  141. loaded: event.loaded,
  142. total: event.total
  143. },
  144. timestamp: Date.now()
  145. });
  146. }
  147. };
  148. xhr.onload = () => {
  149. if (xhr.status >= 200 && xhr.status < 300) {
  150. // 获取ETag(MinIO返回的标识)
  151. const etag = xhr.getResponseHeader('ETag')?.replace(/"/g, '') || '';
  152. resolve(etag);
  153. } else {
  154. reject(new Error(`上传片段失败: ${xhr.status} ${xhr.statusText}`));
  155. }
  156. };
  157. xhr.onerror = () => reject(new Error('上传片段失败'));
  158. xhr.open('PUT', uploadUrl);
  159. xhr.send(partBlob);
  160. if (callbacks?.signal) {
  161. callbacks.signal.addEventListener('abort', () => {
  162. xhr.abort();
  163. reject(new Error('上传已取消'));
  164. });
  165. }
  166. });
  167. }
  168. // 完成分段上传
  169. private static async completeMultipartUpload(
  170. policy: MinioMultipartUploadPolicy,
  171. key: string,
  172. uploadedParts: UploadPart[]
  173. ): Promise<void> {
  174. const response = await fileClient["multipart-complete"].$post({
  175. json:{
  176. bucket: policy.bucket,
  177. key,
  178. uploadId: policy.uploadId,
  179. parts: uploadedParts.map(part => ({ partNumber: part.PartNumber, etag: part.ETag }))
  180. }
  181. });
  182. if (!response.ok) {
  183. throw new Error(`完成分段上传失败: ${response.status} ${response.statusText}`);
  184. }
  185. }
  186. }
  187. export class MinIOXHRUploader {
  188. /**
  189. * 使用XHR上传文件到MinIO
  190. */
  191. static upload(
  192. policy: MinioUploadPolicy,
  193. file: File | Blob,
  194. key: string,
  195. callbacks?: MinioProgressCallbacks
  196. ): Promise<UploadResult> {
  197. const formData = new FormData();
  198. // 添加 MinIO 需要的字段
  199. Object.entries(policy.uploadPolicy).forEach(([k, value]) => {
  200. // 排除 policy 中的 key、host、prefix、ossType 字段
  201. if (k !== 'key' && k !== 'host' && k !== 'prefix' && k !== 'ossType' && typeof value === 'string') {
  202. formData.append(k, value);
  203. }
  204. });
  205. // 添加 自定义 key 字段
  206. formData.append('key', key);
  207. formData.append('file', file);
  208. return new Promise((resolve, reject) => {
  209. const xhr = new XMLHttpRequest();
  210. // 上传进度处理
  211. if (callbacks?.onProgress) {
  212. xhr.upload.onprogress = (event) => {
  213. if (event.lengthComputable) {
  214. callbacks.onProgress?.({
  215. stage: 'uploading',
  216. message: '正在上传文件...',
  217. progress: Math.round((event.loaded * 100) / event.total),
  218. details: {
  219. loaded: event.loaded,
  220. total: event.total
  221. },
  222. timestamp: Date.now()
  223. });
  224. }
  225. };
  226. }
  227. // 完成处理
  228. xhr.onload = () => {
  229. if (xhr.status >= 200 && xhr.status < 300) {
  230. if (callbacks?.onProgress) {
  231. callbacks.onProgress({
  232. stage: 'complete',
  233. message: '文件上传完成',
  234. progress: 100,
  235. timestamp: Date.now()
  236. });
  237. }
  238. callbacks?.onComplete?.();
  239. resolve({
  240. fileUrl:`${policy.uploadPolicy.host}/${key}`,
  241. fileKey: key,
  242. bucketName: policy.uploadPolicy.bucket
  243. });
  244. } else {
  245. const error = new Error(`上传失败: ${xhr.status} ${xhr.statusText}`);
  246. callbacks?.onError?.(error);
  247. reject(error);
  248. }
  249. };
  250. // 错误处理
  251. xhr.onerror = () => {
  252. const error = new Error('上传失败');
  253. if (callbacks?.onProgress) {
  254. callbacks.onProgress({
  255. stage: 'error',
  256. message: '文件上传失败',
  257. progress: 0,
  258. timestamp: Date.now()
  259. });
  260. }
  261. callbacks?.onError?.(error);
  262. reject(error);
  263. };
  264. // 根据当前页面协议和 host 配置决定最终的上传地址
  265. const currentProtocol = typeof window !== 'undefined' ? window.location.protocol : 'https:';
  266. const host = policy.uploadPolicy.host?.startsWith('http')
  267. ? policy.uploadPolicy.host
  268. : `${currentProtocol}//${policy.uploadPolicy.host}`;
  269. // 开始上传
  270. xhr.open('POST', host);
  271. xhr.send(formData);
  272. // 处理取消
  273. if (callbacks?.signal) {
  274. callbacks.signal.addEventListener('abort', () => {
  275. xhr.abort();
  276. reject(new Error('上传已取消'));
  277. });
  278. }
  279. });
  280. }
  281. }
  282. export async function getUploadPolicy(key: string, fileName: string, fileType?: string, fileSize?: number): Promise<MinioUploadPolicy> {
  283. const policyResponse = await fileClient["upload-policy"].$post({
  284. json: {
  285. path: key,
  286. name: fileName,
  287. type: fileType,
  288. size: fileSize
  289. }
  290. });
  291. if (!policyResponse.ok) {
  292. throw new Error('获取上传策略失败');
  293. }
  294. return policyResponse.json();
  295. }
  296. export async function getMultipartUploadPolicy(totalSize: number, fileKey: string, fileType?: string, fileName: string = 'unnamed-file') {
  297. const policyResponse = await fileClient["multipart-policy"].$post({
  298. json: {
  299. totalSize,
  300. partSize: PART_SIZE,
  301. fileKey,
  302. type: fileType,
  303. name: fileName
  304. }
  305. });
  306. if (!policyResponse.ok) {
  307. throw new Error('获取分段上传策略失败');
  308. }
  309. return await policyResponse.json();
  310. }
  311. export async function uploadMinIOWithPolicy(
  312. uploadPath: string,
  313. file: File | Blob,
  314. fileKey: string,
  315. callbacks?: MinioProgressCallbacks
  316. ): Promise<UploadResult> {
  317. if(uploadPath === '/') uploadPath = '';
  318. else{
  319. if(!uploadPath.endsWith('/')) uploadPath = `${uploadPath}/`
  320. // 去掉开头的 /
  321. if(uploadPath.startsWith('/')) uploadPath = uploadPath.replace(/^\//, '');
  322. }
  323. if( file.size > PART_SIZE ){
  324. if (!(file instanceof File)) {
  325. throw new Error('不支持的文件类型,无法获取文件名');
  326. }
  327. const policy = await getMultipartUploadPolicy(
  328. file.size,
  329. `${uploadPath}${fileKey}`,
  330. file.type,
  331. file.name
  332. );
  333. return MinIOXHRMultipartUploader.upload(
  334. policy,
  335. file,
  336. policy.key,
  337. callbacks
  338. );
  339. }else{
  340. if (!(file instanceof File)) {
  341. throw new Error('不支持的文件类型,无法获取文件名');
  342. }
  343. const policy = await getUploadPolicy(`${uploadPath}${fileKey}`, file.name, file.type, file.size);
  344. return MinIOXHRUploader.upload(policy, file, policy.uploadPolicy.key, callbacks);
  345. }
  346. }