hookehuyr

feat(上传): 替换浏览器MD5为七牛云ETag计算方案

添加 rusha 依赖并实现 qiniuFileHash 工具函数
统一所有文件上传场景使用七牛云ETag计算方式
优化大文件分块计算性能并添加进度回调
...@@ -55,6 +55,7 @@ ...@@ -55,6 +55,7 @@
55 "less": "^4.2.2", 55 "less": "^4.2.2",
56 "postcss": "^8.4.35", 56 "postcss": "^8.4.35",
57 "qs": "^6.14.0", 57 "qs": "^6.14.0",
58 + "rusha": "^0.8.14",
58 "tailwindcss": "^3.4.1", 59 "tailwindcss": "^3.4.1",
59 "unplugin-auto-import": "^19.1.1", 60 "unplugin-auto-import": "^19.1.1",
60 "unplugin-vue-components": "^28.4.1", 61 "unplugin-vue-components": "^28.4.1",
......
...@@ -32,7 +32,6 @@ declare module 'vue' { ...@@ -32,7 +32,6 @@ declare module 'vue' {
32 RouterView: typeof import('vue-router')['RouterView'] 32 RouterView: typeof import('vue-router')['RouterView']
33 SearchBar: typeof import('./components/ui/SearchBar.vue')['default'] 33 SearchBar: typeof import('./components/ui/SearchBar.vue')['default']
34 SummerCampCard: typeof import('./components/ui/SummerCampCard.vue')['default'] 34 SummerCampCard: typeof import('./components/ui/SummerCampCard.vue')['default']
35 - TeacherFilter: typeof import('./components/ui/TeacherFilter.vue')['default']
36 TermsPopup: typeof import('./components/ui/TermsPopup.vue')['default'] 35 TermsPopup: typeof import('./components/ui/TermsPopup.vue')['default']
37 UploadVideoPopup: typeof import('./components/ui/UploadVideoPopup.vue')['default'] 36 UploadVideoPopup: typeof import('./components/ui/UploadVideoPopup.vue')['default']
38 UserAgreement: typeof import('./components/ui/UserAgreement.vue')['default'] 37 UserAgreement: typeof import('./components/ui/UserAgreement.vue')['default']
......
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 +}
1 import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from '@/api/common'; 1 import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from '@/api/common';
2 -import BMF from 'browser-md5-file'; 2 +import { qiniuFileHash } from '@/utils/qiniuFileHash';
3 // import { v4 as uuidv4 } from 'uuid'; 3 // import { v4 as uuidv4 } from 'uuid';
4 4
5 // 获取文件后缀 5 // 获取文件后缀
...@@ -7,19 +7,15 @@ const getFileSuffix = (fileName) => { ...@@ -7,19 +7,15 @@ const getFileSuffix = (fileName) => {
7 return /.[^.]+$/.exec(fileName) || ''; 7 return /.[^.]+$/.exec(fileName) || '';
8 }; 8 };
9 9
10 -// 获取文件MD5 10 +/**
11 -const getFileMD5 = (file) => { 11 + * 获取文件哈希(与七牛云ETag一致)
12 - return new Promise((resolve, reject) => { 12 + * @param {File} file 文件对象
13 - const bmf = new BMF(); 13 + * @returns {Promise<string>} 哈希字符串
14 - bmf.md5(file, (err, md5) => { 14 + * 注释:使用 qiniuFileHash 进行计算,替代浏览器MD5方案。
15 - if (err) { 15 + */
16 - reject(err); 16 +const getFileMD5 = async (file) => {
17 - return; 17 + return await qiniuFileHash(file)
18 - } 18 +}
19 - resolve(md5);
20 - });
21 - });
22 -};
23 19
24 // 上传文件到七牛云 20 // 上传文件到七牛云
25 const uploadToQiniu = async (file, token, fileName, onProgress) => { 21 const uploadToQiniu = async (file, token, fileName, onProgress) => {
......
...@@ -72,7 +72,7 @@ import { useRoute, useRouter } from 'vue-router' ...@@ -72,7 +72,7 @@ import { useRoute, useRouter } from 'vue-router'
72 import { showToast, showLoadingToast } from 'vant' 72 import { showToast, showLoadingToast } from 'vant'
73 import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from '@/api/common' 73 import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from '@/api/common'
74 import { addUploadTaskAPI, getUploadTaskInfoAPI, editUploadTaskInfoAPI } from "@/api/checkin"; 74 import { addUploadTaskAPI, getUploadTaskInfoAPI, editUploadTaskInfoAPI } from "@/api/checkin";
75 -import BMF from 'browser-md5-file' 75 +import { qiniuFileHash } from '@/utils/qiniuFileHash';
76 import _ from 'lodash' 76 import _ from 'lodash'
77 import { useTitle } from '@vueuse/core'; 77 import { useTitle } from '@vueuse/core';
78 import { useAuth } from '@/contexts/auth' 78 import { useAuth } from '@/contexts/auth'
...@@ -134,18 +134,14 @@ const beforeRead = (file) => { ...@@ -134,18 +134,14 @@ const beforeRead = (file) => {
134 return flag 134 return flag
135 } 135 }
136 136
137 -// 获取文件MD5 137 +/**
138 -const getFileMD5 = (file) => { 138 + * 获取文件哈希(与七牛云ETag一致)
139 - return new Promise((resolve, reject) => { 139 + * @param {File} file 文件对象
140 - const bmf = new BMF() 140 + * @returns {Promise<string>} 哈希字符串
141 - bmf.md5(file, (err, md5) => { 141 + * 注释:使用 qiniuFileHash 进行计算,替代浏览器MD5方案。
142 - if (err) { 142 + */
143 - reject(err) 143 +const getFileMD5 = async (file) => {
144 - return 144 + return await qiniuFileHash(file)
145 - }
146 - resolve(md5)
147 - })
148 - })
149 } 145 }
150 146
151 // 上传到七牛云 147 // 上传到七牛云
......
...@@ -58,7 +58,7 @@ import { useRoute, useRouter } from 'vue-router' ...@@ -58,7 +58,7 @@ import { useRoute, useRouter } from 'vue-router'
58 import { showToast, showLoadingToast } from 'vant' 58 import { showToast, showLoadingToast } from 'vant'
59 import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from '@/api/common' 59 import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from '@/api/common'
60 import { addUploadTaskAPI, getUploadTaskInfoAPI, editUploadTaskInfoAPI } from "@/api/checkin"; 60 import { addUploadTaskAPI, getUploadTaskInfoAPI, editUploadTaskInfoAPI } from "@/api/checkin";
61 -import BMF from 'browser-md5-file' 61 +import { qiniuFileHash } from '@/utils/qiniuFileHash';
62 import _ from 'lodash' 62 import _ from 'lodash'
63 import { useTitle } from '@vueuse/core'; 63 import { useTitle } from '@vueuse/core';
64 import { useAuth } from '@/contexts/auth' 64 import { useAuth } from '@/contexts/auth'
...@@ -130,18 +130,14 @@ const beforeRead = (file) => { ...@@ -130,18 +130,14 @@ const beforeRead = (file) => {
130 return flag 130 return flag
131 } 131 }
132 132
133 -// 获取文件MD5 133 +/**
134 -const getFileMD5 = (file) => { 134 + * 获取文件哈希(与七牛云ETag一致)
135 - return new Promise((resolve, reject) => { 135 + * @param {File} file 文件对象
136 - const bmf = new BMF() 136 + * @returns {Promise<string>} 哈希字符串
137 - bmf.md5(file, (err, md5) => { 137 + * 注释:使用 qiniuFileHash 进行计算,替代浏览器MD5方案。
138 - if (err) { 138 + */
139 - reject(err) 139 +const getFileMD5 = async (file) => {
140 - return 140 + return await qiniuFileHash(file)
141 - }
142 - resolve(md5)
143 - })
144 - })
145 } 141 }
146 142
147 // 上传到七牛云 143 // 上传到七牛云
......
...@@ -72,7 +72,7 @@ import { useRoute, useRouter } from 'vue-router' ...@@ -72,7 +72,7 @@ import { useRoute, useRouter } from 'vue-router'
72 import { showToast, showLoadingToast } from 'vant' 72 import { showToast, showLoadingToast } from 'vant'
73 import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from '@/api/common' 73 import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from '@/api/common'
74 import { addUploadTaskAPI, getUploadTaskInfoAPI, editUploadTaskInfoAPI } from "@/api/checkin"; 74 import { addUploadTaskAPI, getUploadTaskInfoAPI, editUploadTaskInfoAPI } from "@/api/checkin";
75 -import BMF from 'browser-md5-file' 75 +import { qiniuFileHash } from '@/utils/qiniuFileHash';
76 import _ from 'lodash' 76 import _ from 'lodash'
77 import { useTitle } from '@vueuse/core'; 77 import { useTitle } from '@vueuse/core';
78 import { useAuth } from '@/contexts/auth' 78 import { useAuth } from '@/contexts/auth'
...@@ -134,18 +134,14 @@ const beforeRead = (file) => { ...@@ -134,18 +134,14 @@ const beforeRead = (file) => {
134 return flag 134 return flag
135 } 135 }
136 136
137 -// 获取文件MD5 137 +/**
138 -const getFileMD5 = (file) => { 138 + * 获取文件哈希(与七牛云ETag一致)
139 - return new Promise((resolve, reject) => { 139 + * @param {File} file 文件对象
140 - const bmf = new BMF() 140 + * @returns {Promise<string>} 哈希字符串
141 - bmf.md5(file, (err, md5) => { 141 + * 注释:使用 qiniuFileHash 进行计算,替代浏览器MD5方案。
142 - if (err) { 142 + */
143 - reject(err) 143 +const getFileMD5 = async (file) => {
144 - return 144 + return await qiniuFileHash(file)
145 - }
146 - resolve(md5)
147 - })
148 - })
149 } 145 }
150 146
151 // 上传到七牛云 147 // 上传到七牛云
......
1 <!-- 1 <!--
2 * @Date: 2025-03-24 13:04:21 2 * @Date: 2025-03-24 13:04:21
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2025-06-09 11:47:32 4 + * @LastEditTime: 2025-11-11 17:09:16
5 * @FilePath: /mlaj/src/views/profile/settings/AvatarSettingPage.vue 5 * @FilePath: /mlaj/src/views/profile/settings/AvatarSettingPage.vue
6 * @Description: 修改头像页面 6 * @Description: 修改头像页面
7 --> 7 -->
...@@ -57,7 +57,7 @@ import FrostedGlass from "@/components/ui/FrostedGlass.vue"; ...@@ -57,7 +57,7 @@ import FrostedGlass from "@/components/ui/FrostedGlass.vue";
57 import { getUserInfoAPI, updateUserInfoAPI } from "@/api/users"; 57 import { getUserInfoAPI, updateUserInfoAPI } from "@/api/users";
58 import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from '@/api/common'; 58 import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from '@/api/common';
59 import { showToast, showLoadingToast } from 'vant'; 59 import { showToast, showLoadingToast } from 'vant';
60 -import BMF from 'browser-md5-file'; 60 +import { qiniuFileHash } from '@/utils/qiniuFileHash';
61 import { useTitle } from '@vueuse/core'; 61 import { useTitle } from '@vueuse/core';
62 import { useAuth } from '@/contexts/auth'; 62 import { useAuth } from '@/contexts/auth';
63 63
...@@ -80,18 +80,14 @@ onMounted(async () => { ...@@ -80,18 +80,14 @@ onMounted(async () => {
80 } 80 }
81 }); 81 });
82 82
83 -// 获取文件MD5 83 +/**
84 -const getFileMD5 = (file) => { 84 + * 获取文件哈希(与七牛云ETag一致)
85 - return new Promise((resolve, reject) => { 85 + * @param {File} file 文件对象
86 - const bmf = new BMF() 86 + * @returns {Promise<string>} 哈希字符串
87 - bmf.md5(file, (err, md5) => { 87 + * 注释:使用 qiniuFileHash 进行计算,替代浏览器MD5方案。
88 - if (err) { 88 + */
89 - reject(err) 89 +const getFileMD5 = async (file) => {
90 - return 90 + return await qiniuFileHash(file)
91 - }
92 - resolve(md5)
93 - })
94 - })
95 } 91 }
96 92
97 // 上传到七牛云 93 // 上传到七牛云
......
...@@ -2364,6 +2364,11 @@ run-parallel@^1.1.9: ...@@ -2364,6 +2364,11 @@ run-parallel@^1.1.9:
2364 dependencies: 2364 dependencies:
2365 queue-microtask "^1.2.2" 2365 queue-microtask "^1.2.2"
2366 2366
2367 +rusha@^0.8.14:
2368 + version "0.8.14"
2369 + resolved "https://registry.npmjs.org/rusha/-/rusha-0.8.14.tgz#a977d0de9428406138b7bb90d3de5dcd024e2f68"
2370 + integrity sha512-cLgakCUf6PedEu15t8kbsjnwIFFR2D4RfL+W3iWFJ4iac7z4B0ZI8fxy4R3J956kAI68HclCFGL8MPoUVC3qVA==
2371 +
2367 rust-result@^1.0.0: 2372 rust-result@^1.0.0:
2368 version "1.0.0" 2373 version "1.0.0"
2369 resolved "https://registry.yarnpkg.com/rust-result/-/rust-result-1.0.0.tgz#34c75b2e6dc39fe5875e5bdec85b5e0f91536f72" 2374 resolved "https://registry.yarnpkg.com/rust-result/-/rust-result-1.0.0.tgz#34c75b2e6dc39fe5875e5bdec85b5e0f91536f72"
......