hookehuyr

feat(文件上传): 使用 rusha 实现七牛云 ETag 计算优化文件上传

替换 browser-md5-file 为 rusha 实现七牛云 ETag 计算算法,提升大文件哈希计算性能
新增 qiniuFileHash 工具函数,支持进度回调
重构 FileUploaderField 和 ImageUploaderField 组件上传逻辑
...@@ -94,6 +94,7 @@ ...@@ -94,6 +94,7 @@
94 "pinia": "^2.0.14", 94 "pinia": "^2.0.14",
95 "postcss-px-to-viewport": "^1.1.1", 95 "postcss-px-to-viewport": "^1.1.1",
96 "qs": "^6.10.3", 96 "qs": "^6.10.3",
97 + "rusha": "^0.8.14",
97 "tslint": "^6.1.3", 98 "tslint": "^6.1.3",
98 "unplugin-auto-import": "^0.8.8", 99 "unplugin-auto-import": "^0.8.8",
99 "unplugin-vue-components": "^0.23.0", 100 "unplugin-vue-components": "^0.23.0",
......
...@@ -92,10 +92,9 @@ import { showSuccessToast, showFailToast, showToast } from "vant"; ...@@ -92,10 +92,9 @@ import { showSuccessToast, showFailToast, showToast } from "vant";
92 import _ from "lodash"; 92 import _ from "lodash";
93 import { v4 as uuidv4 } from "uuid"; 93 import { v4 as uuidv4 } from "uuid";
94 import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from "@/api/common"; 94 import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from "@/api/common";
95 -import BMF from "browser-md5-file"; 95 +import { qiniuFileHash } from "@/utils/qiniuFileHash.js";
96 import { useRoute } from "vue-router"; 96 import { useRoute } from "vue-router";
97 import axios from "axios"; 97 import axios from "axios";
98 -import { getEtag } from "@/utils/qetag.js"; // 生成hash值
99 import { wxInfo } from "@/utils/tools"; 98 import { wxInfo } from "@/utils/tools";
100 99
101 const $route = useRoute(); 100 const $route = useRoute();
...@@ -320,47 +319,45 @@ const beforeDelete = (files) => { ...@@ -320,47 +319,45 @@ const beforeDelete = (files) => {
320 const loading = ref(false); 319 const loading = ref(false);
321 const formCode = $route.query.code; // 表单code 320 const formCode = $route.query.code; // 表单code
322 321
323 -// 上传文件返回文件URL 322 +/**
323 + * 上传文件并返回文件URL
324 + * @param {Object} files 当前上传项(van-uploader传入对象)
325 + * @returns {Promise<Object>} 服务端返回的文件信息
326 + * @description 使用 qiniuFileHash 计算文件哈希(遵循七牛ETag),替换原MD5逻辑
327 + */
324 const handleUpload = async (files) => { 328 const handleUpload = async (files) => {
325 - loading.value = true; 329 + loading.value = true;
326 - // 获取HASH值 330 + return new Promise(async (resolve, reject) => {
327 - // const hash = getEtag(files.content); 331 + try {
328 - return new Promise((resolve, reject) => { 332 + // 计算文件哈希(ETag),用于服务端去重及生成上传key
329 - // 获取MD5值 333 + const md5 = await qiniuFileHash(files.file, (progress) => {
330 - const bmf = new BMF(); 334 + // 计算进度(此处保留钩子,便于后续展示进度)
331 - bmf.md5( 335 + });
332 - files.file, 336 + // 获取七牛token
333 - async (err, md5) => { 337 + const filename = files.file.name; // 真实文件名
334 - if (err) { 338 + const getToken = await qiniuTokenAPI({
335 - console.log(err); 339 + name: filename,
336 - reject(err); 340 + hash: md5,
337 - } 341 + });
338 - // 获取七牛token 342 + // 文件上传七牛云
339 - const filename = files.file.name; // 真实文件名 343 + let imgUrl = "";
340 - const getToken = await qiniuTokenAPI({ 344 + // 第一次上传
341 - name: filename, 345 + if (getToken.token) {
342 - hash: md5, 346 + files.status = "uploading";
343 - }); 347 + files.message = "上传中...";
344 - // 文件上传七牛云 348 + // 返回数据库真实文件地址
345 - let imgUrl = ""; 349 + imgUrl = await uploadQiniu(files.file, getToken.token, filename, md5);
346 - // 第一次上传 350 + }
347 - if (getToken.token) { 351 + // 重复上传
348 - files.status = "uploading"; 352 + if (getToken.data) {
349 - files.message = "上传中..."; 353 + imgUrl = getToken.data;
350 - // 返回数据库真实文件地址 354 + }
351 - imgUrl = await uploadQiniu(files.file, getToken.token, filename, md5); 355 + resolve(imgUrl);
352 - } 356 + } catch (err) {
353 - // 重复上传 357 + console.log(err);
354 - if (getToken.data) { 358 + reject(err);
355 - imgUrl = getToken.data;
356 } 359 }
357 - resolve(imgUrl); 360 + });
358 - },
359 - (process) => {
360 - //计算进度
361 - }
362 - );
363 - });
364 }; 361 };
365 362
366 // 多选文件上传遍历 363 // 多选文件上传遍历
......
1 <!-- 1 <!--
2 * @Date: 2022-08-31 16:16:49 2 * @Date: 2022-08-31 16:16:49
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2024-11-14 13:06:43 4 + * @LastEditTime: 2025-11-11 17:27:12
5 * @FilePath: /data-table/src/components/ImageUploaderField/index.vue 5 * @FilePath: /data-table/src/components/ImageUploaderField/index.vue
6 * @Description: 图片上传控件 6 * @Description: 图片上传控件
7 --> 7 -->
...@@ -75,10 +75,9 @@ import { showSuccessToast, showFailToast, showToast, showImagePreview } from "va ...@@ -75,10 +75,9 @@ import { showSuccessToast, showFailToast, showToast, showImagePreview } from "va
75 import _ from "lodash"; 75 import _ from "lodash";
76 import { v4 as uuidv4 } from "uuid"; 76 import { v4 as uuidv4 } from "uuid";
77 import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from "@/api/common"; 77 import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from "@/api/common";
78 -import BMF from "browser-md5-file"; 78 +import { qiniuFileHash } from "@/utils/qiniuFileHash.js";
79 import { useRoute } from "vue-router"; 79 import { useRoute } from "vue-router";
80 import axios from "axios"; 80 import axios from "axios";
81 -import { getEtag } from "@/utils/qetag.js"; // 生成hash值
82 81
83 const $route = useRoute(); 82 const $route = useRoute();
84 const props = defineProps({ 83 const props = defineProps({
...@@ -257,47 +256,45 @@ const formCode = $route.query.code; // 表单code ...@@ -257,47 +256,45 @@ const formCode = $route.query.code; // 表单code
257 // return uuid; 256 // return uuid;
258 // }; 257 // };
259 258
260 -// 上传图片返回图片URL 259 +/**
260 + * 上传图片并返回图片URL
261 + * @param {Object} files 当前上传项(van-uploader传入对象)
262 + * @returns {Promise<Object>} 服务端返回的图片信息
263 + * @description 使用 qiniuFileHash 计算文件哈希(遵循七牛ETag),替换原MD5逻辑
264 + */
261 const handleUpload = async (files) => { 265 const handleUpload = async (files) => {
262 - loading.value = true; 266 + loading.value = true;
263 - // 获取HASH值 267 + return new Promise(async (resolve, reject) => {
264 - // const hash = getEtag(files.content); 268 + try {
265 - return new Promise((resolve, reject) => { 269 + // 计算文件哈希(ETag),用于服务端去重及生成上传key
266 - // 获取MD5值 270 + const md5 = await qiniuFileHash(files.file, (progress) => {
267 - const bmf = new BMF(); 271 + // 计算进度(此处保留钩子,便于后续展示进度)
268 - bmf.md5( 272 + });
269 - files.file, 273 + // 获取七牛token
270 - async (err, md5) => { 274 + const filename = files.file.name; // 真实文件名
271 - if (err) { 275 + const getToken = await qiniuTokenAPI({
272 - console.log(err); 276 + name: filename,
273 - reject(err); 277 + hash: md5,
274 - } 278 + });
275 - // 获取七牛token 279 + // 文件上传七牛云
276 - const filename = files.file.name; // 真实文件名 280 + let imgUrl = "";
277 - const getToken = await qiniuTokenAPI({ 281 + // 第一次上传
278 - name: filename, 282 + if (getToken.token) {
279 - hash: md5, 283 + files.status = "uploading";
280 - }); 284 + files.message = "上传中...";
281 - // 文件上传七牛云 285 + // 返回数据库真实图片地址
282 - let imgUrl = ""; 286 + imgUrl = await uploadQiniu(files.file, getToken.token, filename, md5);
283 - // 第一次上传 287 + }
284 - if (getToken.token) { 288 + // 重复上传
285 - files.status = "uploading"; 289 + if (getToken.data) {
286 - files.message = "上传中..."; 290 + imgUrl = getToken.data;
287 - // 返回数据库真实图片地址 291 + }
288 - imgUrl = await uploadQiniu(files.file, getToken.token, filename, md5); 292 + resolve(imgUrl);
293 + } catch (err) {
294 + console.log(err);
295 + reject(err);
289 } 296 }
290 - // 重复上传 297 + });
291 - if (getToken.data) {
292 - imgUrl = getToken.data;
293 - }
294 - resolve(imgUrl);
295 - },
296 - (process) => {
297 - //计算进度
298 - }
299 - );
300 - });
301 }; 298 };
302 299
303 // 多选图片上传遍历 300 // 多选图片上传遍历
......
1 +import Rusha from 'rusha'
2 +/**
3 + * 完全遵循七牛云对象存储(Qiniu Kodo)的 ETag 计算规则,在浏览器中计算文件 ETag。
4 + *
5 + * @param {File} file 用户通过 <input> 选择的 File 对象
6 + * @param {(progress: { computed: number, total: number, percent: number }) => void} [progressCallback] 计算进度的回调函数
7 + * @returns {Promise<string>} 一个 Promise,最终解析为计算出的 eTag 字符串
8 + */
9 +export const qiniuFileHash = async(file, progressCallback) => {
10 + // --- 辅助函数 ---
11 +
12 + // 1. SHA-1 计算
13 + const createSha1 = window.crypto && window.crypto.subtle
14 + ? (data) => window.crypto.subtle.digest('SHA-1', data) // 使用 Web Crypto API
15 + : (data) => Rusha.createHash().update(data).digest(); // https://github.com/srijs/rusha
16 +
17 + // 2. 拼接 ArrayBuffer
18 + const concatArrayBuffers = (buffers) => {
19 + const totalLength = buffers.reduce((acc, b) => acc + b.byteLength, 0);
20 + const result = new Uint8Array(totalLength);
21 + let offset = 0;
22 + for (const buffer of buffers) {
23 + result.set(new Uint8Array(buffer), offset);
24 + offset += buffer.byteLength;
25 + }
26 + return result.buffer;
27 + };
28 +
29 + // 3. ArrayBuffer 到 URL 安全 Base64 的转换
30 + // 使用一个更健壮的 Base64 编码函数,避免大文件时出现栈溢出
31 + const urlSafeBase64Encode = (buffer) => {
32 + let binary = '';
33 + const bytes = new Uint8Array(buffer);
34 + // 对于小数据量 (21字节),这种方式性能足够,且比 fromCharCode.apply 更安全
35 + for (let i = 0; i < bytes.byteLength; i++) {
36 + binary += String.fromCharCode(bytes[i]);
37 + }
38 + return btoa(binary).replace(/\//g, '_').replace(/\+/g, '-');
39 + };
40 +
41 + // 4. 带节流的进度回调包装器
42 + let lastUpdateTime = 0;
43 + const throttleInterval = 100; // 每 100ms 更新一次进度
44 +
45 + const throttledProgressCallback = (progress) => {
46 + if (!progressCallback) return;
47 +
48 + const now = Date.now();
49 + // 对于最后 100% 的进度,我们总是希望它被立即调用,以确保状态最终正确。
50 + if (progress.percent === 100) {
51 + setTimeout(() => progressCallback(progress), 0);
52 + return;
53 + }
54 +
55 + if (now - lastUpdateTime > throttleInterval) {
56 + lastUpdateTime = now;
57 + // 使用 setTimeout 解耦,防止阻塞
58 + setTimeout(() => progressCallback(progress), 0);
59 + }
60 + };
61 +
62 + // --- 主逻辑 ---
63 +
64 + if (file.size === 0) {
65 + throttledProgressCallback({ computed: 0, total: 0, percent: 100 });
66 + return 'Fto5o-5ea0sNMlW_75VgGJCv2AcJ';
67 + }
68 +
69 + const blockSize = 4 * 1024 * 1024; // 4MB
70 +
71 + // --- 1. 小文件处理 (小于等于4MB) ---
72 + if (file.size <= blockSize) {
73 + const fileBuffer = await file.arrayBuffer();
74 + const sha1Buffer = await createSha1(fileBuffer);
75 + const prefix = new Uint8Array([0x16]);
76 + const finalBuffer = concatArrayBuffers([prefix.buffer, sha1Buffer]);
77 + const hash = urlSafeBase64Encode(finalBuffer);
78 + throttledProgressCallback({ computed: file.size, total: file.size, percent: 100 });
79 + return hash;
80 + }
81 +
82 + // --- 2. 大文件处理 (需要分块) ---
83 + const sha1Results = []; // 存放每个完整块的 SHA-1 结果
84 + let computed = 0;
85 +
86 + // 尝试使用高性能的 BYOB 流模式
87 + let reader;
88 + try {
89 + const stream = file.stream();
90 + reader = stream.getReader({ mode: 'byob' });
91 + } catch (error) {
92 + // console.warn("BYOB reader not supported, falling back to slice() mode.", error);
93 + }
94 +
95 + if (reader) {
96 + // --- 2a. 高性能 BYOB 流模式 ---
97 + let buffer = new Uint8Array(blockSize); // 我们需要一个缓冲区来累积数据,直到达到 4MB
98 + let offset = 0; // 当前缓冲区已填充的数据量
99 +
100 + while (true) {
101 + // BYOB 读取器需要一个视图 (view) 来写入数据
102 + // 我们让它写入到我们累积缓冲区的剩余空间
103 + // 每次都基于当前 buffer 和 offset 创建 view
104 + const view = new Uint8Array(buffer.buffer, offset, buffer.byteLength - offset);
105 + const { done, value } = await reader.read(view);
106 +
107 + // 恢复对 buffer 的引用,因为 read() 后它可能被转移 (detached)
108 + buffer = new Uint8Array(value.buffer);
109 +
110 + if (done) {
111 + // 文件读取完毕,处理最后一个不满 4MB 的块
112 + if (offset > 0) {
113 + const finalChunkView = new Uint8Array(buffer.buffer, 0, offset);
114 + const chunkSha1 = await createSha1(finalChunkView);
115 + // 最后一次计算文件分片时,不给计算进度,在所有计算都完成后,在给出计算进度
116 + computed += finalChunkView.byteLength;
117 + sha1Results.push(chunkSha1);
118 + }
119 + break;
120 + }
121 +
122 + // 更新偏移量,value.byteLength 是本次实际读取到的字节数
123 + offset += value.byteLength;
124 +
125 + // 检查缓冲区是否已满
126 + if (offset === blockSize) {
127 + // 缓冲区满了,计算整个块的 SHA-1
128 + const chunkSha1 = await createSha1(buffer);
129 + computed += buffer.byteLength;
130 + throttledProgressCallback({ computed: computed, total: file.size, percent: computed / file.size * 100 });
131 + sha1Results.push(chunkSha1);
132 + offset = 0; // 重置偏移,复用 buffer
133 + }
134 + }
135 + reader.releaseLock(); // 释放流的锁
136 + }
137 + else {
138 + // --- 2b. 回退到 slice() 并行模式 ---
139 + const blockCount = Math.ceil(file.size / blockSize);
140 + const promises = [];
141 +
142 + for (let i = 0; i < blockCount; i++) {
143 + const start = i * blockSize;
144 + const end = Math.min(start + blockSize, file.size);
145 + const chunk = file.slice(start, end);
146 +
147 + const promise = (async () => {
148 + const buffer = await chunk.arrayBuffer();
149 + const chunkSha1 = await createSha1(buffer);
150 + computed += buffer.byteLength;
151 + if (computed < file.size) {
152 + // 最后一次计算文件分片时,不给计算进度,在所有计算都完成后,在给出计算进度
153 + throttledProgressCallback({ computed: computed, total: file.size, percent: computed / file.size * 100 });
154 + }
155 + return chunkSha1;
156 + })();
157 + promises.push(promise);
158 + }
159 +
160 + // 使用 Promise.all 并行执行所有块的读取和 SHA-1 计算
161 + const resolvedSha1s = await Promise.all(promises);
162 + sha1Results.push(...resolvedSha1s);
163 + }
164 +
165 + // --- 3. 最终计算 ---
166 + // 所有块的 SHA-1 计算完毕,进行最终的合并计算
167 + const concatenatedSha1s = concatArrayBuffers(sha1Results);
168 + const finalSha1 = await createSha1(concatenatedSha1s);
169 + const prefix = new Uint8Array([0x96]);
170 + const finalBuffer = concatArrayBuffers([prefix.buffer, finalSha1]);
171 +
172 + const hash = urlSafeBase64Encode(finalBuffer);
173 + throttledProgressCallback({ computed: file.size, total: file.size, percent: 100 });
174 + return hash;
175 +}
...@@ -3066,6 +3066,11 @@ run-parallel@^1.1.9: ...@@ -3066,6 +3066,11 @@ run-parallel@^1.1.9:
3066 dependencies: 3066 dependencies:
3067 queue-microtask "^1.2.2" 3067 queue-microtask "^1.2.2"
3068 3068
3069 +rusha@^0.8.14:
3070 + version "0.8.14"
3071 + resolved "https://registry.npmjs.org/rusha/-/rusha-0.8.14.tgz#a977d0de9428406138b7bb90d3de5dcd024e2f68"
3072 + integrity sha512-cLgakCUf6PedEu15t8kbsjnwIFFR2D4RfL+W3iWFJ4iac7z4B0ZI8fxy4R3J956kAI68HclCFGL8MPoUVC3qVA==
3073 +
3069 rxjs@^7.5.1: 3074 rxjs@^7.5.1:
3070 version "7.5.6" 3075 version "7.5.6"
3071 resolved "https://mirrors.cloud.tencent.com/npm/rxjs/-/rxjs-7.5.6.tgz" 3076 resolved "https://mirrors.cloud.tencent.com/npm/rxjs/-/rxjs-7.5.6.tgz"
......