二维码离线传输2.0

前言

在今年2月份的时候,我有尝试使用二维码来离线传输图片,虽然这是一个不错的想法,但是在当时并没有取得成功(属于是一个差一点就成功)。
不过好在我又进行了一次尝试,我选择了另一个道路来完成这个项目。
并且,这个2.0版本得到了升级,现在,它不仅可以适用于图片,更覆盖所有文件。
出于篇幅问题,有些地方我就不放源代码了,如果想看我与AI的对话,可以看末尾的链接。

想法?

我本来计划沿用之前的python来完成,因为这种方法相较于C语言较简单,但是…在我换了电脑后,我不知道在旧电脑都放了什么库进去,而且原本的文件中有一些错误,包括像二维码导出问题,识别问题。
我对此头皮发麻,这些问题比较难以处理…
所以我在放弃的前一秒选择了另一个赛道——HTML
其实在使用它的时候我又在想,html可不可以实现我的想法?我起初有些害怕。但在我用AI生成的时候,居然成功了!
我很惊讶,赶忙对这个项目进行了跟进处理。

成功…了?

在和AI进行多次交涉之后,第一个版本出现了。
注:第一个版本是python
结构:
项目结构建议:

1
2
3
4
5
6
7
8
9
10
qr_file_transfer/
├── app.py # Flask 主应用
├── templates/
│ ├── index.html # 上传和展示页面
│ └── scanner.html # 扫描页面 (基础)
├── static/
│ └── styles.css # 样式文件
├── uploads/ # 上传文件临时存储
├── qr_codes/ # 生成的二维码图片存储
└── received_files/ # 接收端重组后的文件存储
源码

1. app.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
import os
import base64
import hashlib
import json
import time
import uuid
from flask import Flask, request, render_template, send_file, jsonify, redirect, url_for
import qrcode
from cryptography.fernet import Fernet

app = Flask(__name__)

# --- 配置 ---
UPLOAD_FOLDER = 'uploads'
QR_CODE_FOLDER = 'qr_codes'
RECEIVED_FOLDER = 'received_files'
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
app.config['QR_CODE_FOLDER'] = QR_CODE_FOLDER
app.config['RECEIVED_FOLDER'] = RECEIVED_FOLDER

# 确保目录存在
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
os.makedirs(QR_CODE_FOLDER, exist_ok=True)
os.makedirs(RECEIVED_FOLDER, exist_ok=True)

# 存储传输会话信息 {session_id: {'key': key, 'chunks': [...], 'metadata': {...}}}
transfer_sessions = {}

# --- 辅助函数 ---

def calculate_sha256(file_path):
"""计算文件的 SHA-256 哈希值"""
sha256_hash = hashlib.sha256()
with open(file_path, "rb") as f:
# Read and update hash string value in blocks of 4K
for byte_block in iter(lambda: f.read(4096), b""):
sha256_hash.update(byte_block)
return sha256_hash.hexdigest()

def split_data(data, chunk_size):
"""将字节数据分割成指定大小的块"""
chunks = []
for i in range(0, len(data), chunk_size):
chunks.append(data[i:i + chunk_size])
return chunks

def generate_qr_code(data_str, filename):
"""生成二维码并保存为文件"""
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=10,
border=4,
)
qr.add_data(data_str)
qr.make(fit=True)

img = qr.make_image(fill_color="black", back_color="white")
img.save(os.path.join(app.config['QR_CODE_FOLDER'], filename))

# --- 路由 ---

@app.route('/')
def index():
"""主页 - 文件上传"""
return render_template('index.html')

@app.route('/upload', methods=['POST'])
def upload_file():
"""处理文件上传和二维码生成"""
if 'files' not in request.files:
return "No file part", 400
files = request.files.getlist('files')

if not files or all(f.filename == '' for f in files):
return "No selected file", 400

# 获取用户设置
try:
chunk_size_kb = int(request.form.get('chunk_size', 100)) # 默认100KB
display_speed = int(request.form.get('display_speed', 2)) # 默认2张/秒
except ValueError:
return "Invalid input for chunk size or display speed", 400

chunk_size_bytes = chunk_size_kb * 1024
delay_ms = int(1000 / display_speed) if display_speed > 0 else 500

# 为本次传输创建唯一会话ID和加密密钥
session_id = str(uuid.uuid4())
key = Fernet.generate_key()
cipher_suite = Fernet(key)

uploaded_files_info = []
all_chunks_data = [] # 存储所有文件的加密块数据

# 1. 处理每个上传的文件
for file in files:
if file:
filename = file.filename
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(filepath)

file_size = os.path.getsize(filepath)
file_hash = calculate_sha256(filepath)
file_type = file.content_type or 'unknown'

# 读取文件内容并Base64编码
with open(filepath, 'rb') as f:
file_content = f.read()
encoded_content = base64.b64encode(file_content).decode('utf-8')

# 准备元数据
metadata = {
'filename': filename,
'size': file_size,
'type': file_type,
'sha256': file_hash
}

# 将元数据和内容组合,然后加密
data_to_encrypt = json.dumps({
'metadata': metadata,
'content': encoded_content
}).encode('utf-8')

encrypted_data = cipher_suite.encrypt(data_to_encrypt)

# 分割加密后的数据
chunks = split_data(encrypted_data, chunk_size_bytes)
all_chunks_data.extend([(chunk, filename) for chunk in chunks]) # 保存块和文件名关联

uploaded_files_info.append({
'name': filename,
'size': file_size,
'hash': file_hash
})

# 2. 为所有块创建二维码
qr_filenames = []
total_chunks = len(all_chunks_data)
for i, (chunk_data, filename) in enumerate(all_chunks_data):
# 构造二维码数据:会话ID, 块索引, 总块数, 文件名标识, 加密块数据
# 为了简化,我们将密钥也编码进去(第一个二维码)
qr_data_dict = {
'session_id': session_id,
'index': i,
'total': total_chunks,
'filename': filename,
'data': base64.b64encode(chunk_data).decode('utf-8')
}

# 第一个二维码包含解密密钥
if i == 0:
qr_data_dict['key'] = key.decode('utf-8')

qr_data_str = json.dumps(qr_data_dict)
qr_filename = f"qr_{session_id}_{i}.png"
generate_qr_code(qr_data_str, qr_filename)
qr_filenames.append(qr_filename)


# 3. 存储会话信息 (虽然本次简化了密钥分发,但保留结构)
transfer_sessions[session_id] = {
'key': key.decode('utf-8'), # 存储为字符串
'total_chunks': total_chunks,
'chunks_received': {}, # {index: data}
'files_info': uploaded_files_info
}

# 4. 渲染展示页面
return render_template('index.html',
qr_files=qr_filenames,
delay_ms=delay_ms,
session_id=session_id,
files_info=uploaded_files_info)


# --- 接收端模拟 (简化版,实际需要前端JS扫描) ---

@app.route('/scanner')
def scanner():
"""扫描页面"""
return render_template('scanner.html')

# 模拟接收端扫描并重组文件的API端点
@app.route('/receive_chunk', methods=['POST'])
def receive_chunk():
"""接收扫描到的二维码数据块 (模拟)"""
data = request.json
qr_data_str = data.get('qr_data')

if not qr_data_str:
return jsonify({'status': 'error', 'message': 'No QR data received'}), 400

try:
qr_data = json.loads(qr_data_str)
except json.JSONDecodeError:
return jsonify({'status': 'error', 'message': 'Invalid QR data format'}), 400

session_id = qr_data.get('session_id')
index = qr_data.get('index')
total = qr_data.get('total')
filename = qr_data.get('filename')
encrypted_chunk_b64 = qr_data.get('data')
key_str = qr_data.get('key') # 可能来自第一个二维码

if not session_id or index is None or total is None or not encrypted_chunk_b64:
return jsonify({'status': 'error', 'message': 'Missing data in QR code'}), 400

# 检查或创建会话
if session_id not in transfer_sessions:
transfer_sessions[session_id] = {
'key': key_str, # 从第一个二维码获取
'total_chunks': total,
'chunks_received': {},
'files_info': {} # 简化处理
}
elif key_str:
transfer_sessions[session_id]['key'] = key_str # 更新密钥(如果第一个二维码后扫到)

session = transfer_sessions[session_id]

# 解密块
try:
cipher_suite = Fernet(session['key'].encode('utf-8'))
encrypted_chunk = base64.b64decode(encrypted_chunk_b64)
decrypted_chunk = cipher_suite.decrypt(encrypted_chunk)

# 解析解密后的内容
chunk_data = json.loads(decrypted_chunk.decode('utf-8'))
# 这里可以进一步处理 chunk_data['metadata'] 和 chunk_data['content']

# 存储解密后的内容(或原始加密块,取决于重组策略)
session['chunks_received'][index] = {
'data': chunk_data, # 存储解密后的完整数据
'filename': filename
}

except Exception as e:
return jsonify({'status': 'error', 'message': f'Decryption failed: {str(e)}'}), 500

# 检查是否所有块都已接收
if len(session['chunks_received']) == session['total_chunks']:
# 重组文件
try:
# 按索引排序
sorted_chunks = sorted(session['chunks_received'].items())

# 假设只有一个文件,或按filename分组处理
# 这里简化为处理第一个文件
if sorted_chunks:
# 重新组装内容 (需要按文件名分组)
files_content = {}
for idx, chunk_info in sorted_chunks:
fname = chunk_info['filename']
content = chunk_info['data']['content'] # Base64内容
if fname not in files_content:
files_content[fname] = []
files_content[fname].append(content)

for fname, parts in files_content.items():
# Base64解码所有部分并合并
full_base64_content = "".join(parts)
file_content = base64.b64decode(full_base64_content)

# 保存文件
output_path = os.path.join(app.config['RECEIVED_FOLDER'], f"received_{fname}")
with open(output_path, 'wb') as f:
f.write(file_content)

# 可以在这里验证SHA256 (chunk_info['data']['metadata']['sha256'])

# 清理会话
del transfer_sessions[session_id]
return jsonify({'status': 'success', 'message': 'File(s) received and saved.', 'download_url': url_for('download_file', filename=f"received_{list(files_content.keys())[0]}")})
else:
return jsonify({'status': 'error', 'message': 'No chunks to assemble'}), 500
except Exception as e:
return jsonify({'status': 'error', 'message': f'File assembly failed: {str(e)}'}), 500

return jsonify({'status': 'success', 'message': f'Chunk {index+1}/{total} received'})


@app.route('/download/<filename>')
def download_file(filename):
"""提供下载重组后的文件"""
try:
return send_file(os.path.join(app.config['RECEIVED_FOLDER'], filename), as_attachment=True)
except FileNotFoundError:
return "File not found", 404


if __name__ == '__main__':
app.run(debug=True)

2. templates/index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
<!DOCTYPE html>
<html>
<head>
<title>QR Code File Transfer - Sender</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles.css') }}">
</head>
<body>
<h1>QR Code File Transfer - Upload</h1>
<form method="post" action="/upload" enctype="multipart/form-data">
<label for="files">Choose files (max 25):</label>
<input type="file" id="files" name="files" multiple required><br><br>

<label for="chunk_size">QR Code Chunk Size (KB):</label>
<input type="number" id="chunk_size" name="chunk_size" value="100" min="1"><br><br>

<label for="display_speed">Display Speed (QR codes/second):</label>
<input type="number" id="display_speed" name="display_speed" value="2" min="1"><br><br>

<input type="submit" value="Upload and Generate QR Codes">
</form>

{% if qr_files %}
<hr>
<h2>File Information:</h2>
<ul>
{% for file in files_info %}
<li>{{ file.name }} ({{ "%.2f" | format(file.size / 1024.0) }} KB, SHA256: {{ file.hash }})</li>
{% endfor %}
</ul>
<h2>Generated QR Codes:</h2>
<!-- 幻灯片容器 -->
<div id="slideshow-container">
{% for qr_file in qr_files %}
<div class="mySlides">
<img src="{{ url_for('static', filename='qr_codes/' + qr_file) }}" style="width:100%">
</div>
{% endfor %}

<!-- Next and previous buttons -->
<a class="prev" onclick="plusSlides(-1)">&#10094;</a>
<a class="next" onclick="plusSlides(1)">&#10095;</a>
</div>
<br>
<!-- The dots/circles -->
<div style="text-align:center">
{% for qr_file in qr_files %}
<span class="dot" onclick="currentSlide({{ loop.index }})"></span>
{% endfor %}
</div>

<h3>Auto-Play Slideshow:</h3>
<button onclick="startSlideshow()">Start</button>
<button onclick="stopSlideshow()">Stop</button>
<p>Speed: {{ delay_ms }} ms per slide</p>

<script>
let slideIndex = 1;
showSlides(slideIndex);
let slideInterval = null;
const delay = {{ delay_ms }}; // milliseconds

function plusSlides(n) {
showSlides(slideIndex += n);
}

function currentSlide(n) {
showSlides(slideIndex = n);
}

function showSlides(n) {
let i;
let slides = document.getElementsByClassName("mySlides");
let dots = document.getElementsByClassName("dot");
if (n > slides.length) {slideIndex = 1}
if (n < 1) {slideIndex = slides.length}
for (i = 0; i < slides.length; i++) {
slides[i].style.display = "none";
}
for (i = 0; i < dots.length; i++) {
dots[i].className = dots[i].className.replace(" active", "");
}
slides[slideIndex-1].style.display = "block";
dots[slideIndex-1].className += " active";
}

function startSlideshow() {
if (slideInterval) stopSlideshow(); // Prevent multiple intervals
slideInterval = setInterval(() => {
plusSlides(1);
}, delay);
}

function stopSlideshow() {
clearInterval(slideInterval);
slideInterval = null;
}

// Optional: Start automatically
// window.onload = function() { startSlideshow(); }
</script>
{% endif %}
</body>
</html>

3. templates/scanner.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
<!DOCTYPE html>
<html>
<head>
<title>QR Code File Transfer - Receiver</title>
<style>
#video {
width: 100%;
max-width: 600px;
height: auto;
border: 1px solid black;
}
#result {
margin-top: 20px;
padding: 10px;
border: 1px solid #ccc;
background-color: #f9f9f9;
}
</style>
</head>
<body>
<h1>QR Code File Transfer - Receiver</h1>
<p>Point your camera at the QR codes being displayed on the sender's screen.</p>

<!-- Scanner UI (需要集成 jsQR 或类似库) -->
<video id="video" width="300" height="200" autoplay muted></video>
<button id="startButton">Start Camera</button>
<button id="stopButton">Stop Camera</button>

<div id="result">
<p>Status: Waiting to start scanning...</p>
<p id="scanInfo">Scanned: 0 / 0</p>
<p id="speedInfo">Speed: 0 Kb/s</p>
<p id="message"></p>
</div>

<script src="https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js"></script>
<script>
const video = document.getElementById('video');
const startButton = document.getElementById('startButton');
const stopButton = document.getElementById('stopButton');
const resultDiv = document.getElementById('result');
const scanInfo = document.getElementById('scanInfo');
const speedInfo = document.getElementById('speedInfo');
const messageP = document.getElementById('message');

let stream = null;
let scanning = false;
let lastScanTime = 0;
let lastScanCount = 0;

const constraints = {
video: {
facingMode: "environment", // Prefer back camera on mobile
width: { ideal: 1280 },
height: { ideal: 720 }
}
};

startButton.addEventListener('click', async () => {
try {
stream = await navigator.mediaDevices.getUserMedia(constraints);
video.srcObject = stream;
scanning = true;
lastScanTime = performance.now();
lastScanCount = 0;
scanContinuously();
} catch (err) {
console.error("Error accessing media devices:", err);
messageP.textContent = "Error accessing camera: " + err.message;
}
});

stopButton.addEventListener('click', () => {
scanning = false;
if (stream) {
const tracks = stream.getTracks();
tracks.forEach(track => track.stop());
video.srcObject = null;
}
messageP.textContent = "Camera stopped.";
});

function scanContinuously() {
if (!scanning) return;

// Use jsQR to scan the video element
const canvasElement = document.createElement('canvas');
const canvas = canvasElement.getContext('2d');
canvasElement.width = video.videoWidth;
canvasElement.height = video.videoHeight;

canvas.drawImage(video, 0, 0, canvasElement.width, canvasElement.height);
const imageData = canvas.getImageData(0, 0, canvasElement.width, canvasElement.height);
const code = jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: "dontInvert",
});

if (code) {
console.log("Scanned QR Code Data:", code.data);
// Send data to backend
fetch('/receive_chunk', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ qr_data: code.data })
})
.then(response => response.json())
.then(data => {
console.log('Success:', data);
messageP.textContent = data.message || "Chunk processed.";
if (data.status === 'success' && data.download_url) {
messageP.innerHTML += ` <a href="${data.download_url}" download>Download File</a>`;
}
// Update scan info (this is simplified, you'd need backend state)
lastScanCount++;
const now = performance.now();
const elapsedSeconds = (now - lastScanTime) / 1000;
if (elapsedSeconds > 1) { // Update speed every second
const speedKbps = (lastScanCount * 100 /* avg chunk size estimate kb */) / elapsedSeconds;
speedInfo.textContent = `Speed: ${speedKbps.toFixed(2)} Kb/s`;
lastScanTime = now;
lastScanCount = 0;
}
scanInfo.textContent = `Scanned: ${lastScanCount} (est.)`; // Placeholder
})
.catch((error) => {
console.error('Error:', error);
messageP.textContent = "Error sending " + error;
});
}
// Continue scanning
requestAnimationFrame(scanContinuously);
}

// Initial check for API support
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
messageP.textContent = "Your browser does not support camera access.";
startButton.disabled = true;
}
if (!window.jsQR) {
messageP.textContent = "QR code scanning library (jsQR) failed to load.";
startButton.disabled = true;
}


</script>
</body>
</html>

4. static/styles.css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
/* Slideshow container */
#slideshow-container {
max-width: 1000px;
position: relative;
margin: auto;
}

/* Hide the images by default */
.mySlides {
display: none;
}

/* Next & previous buttons */
.prev, .next {
cursor: pointer;
position: absolute;
top: 50%;
width: auto;
padding: 16px;
margin-top: -22px;
color: white;
font-weight: bold;
font-size: 18px;
transition: 0.6s ease;
border-radius: 0 3px 3px 0;
user-select: none;
}

/* Position the "next button" to the right */
.next {
right: 0;
border-radius: 3px 0 0 3px;
}

/* On hover, add a black background color with a little bit see-through */
.prev:hover, .next:hover {
background-color: rgba(0,0,0,0.8);
}

/* Caption text */
.text {
color: #f2f2f2;
font-size: 15px;
padding: 8px 12px;
position: absolute;
bottom: 8px;
width: 100%;
text-align: center;
}

/* Number text (1/3 etc) */
.numbertext {
color: #f2f2f2;
font-size: 12px;
padding: 8px 12px;
position: absolute;
top: 0;
}

/* The dots/bullets/indicators */
.dot {
cursor: pointer;
height: 15px;
width: 15px;
margin: 0 2px;
background-color: #bbb;
border-radius: 50%;
display: inline-block;
transition: background-color 0.6s ease;
}

.active, .dot:hover {
background-color: #717171;
}

/* Fading animation */
.fade {
animation-name: fade;
animation-duration: 1.5s;
}

@keyframes fade {
from {opacity: .4}
to {opacity: 1}
}

实际上,这个代码有问题,而且问题还不小。
为了不拉长战线,我们直接跳到HTML的第一个版本:

我询问AI的问题:
如果将该项目写成可以放在github静态托管的全网页形式,生成的文件(包括但不限于二维码文件,扫描后结果图)放在缓存里,后期可供用户下载,又应该怎么写?请你输出完整代码,框架。以及实现方式

没想到AI还真的得到了我想初期达到的效果:

详细信息
  1. 核心技术

    • HTML/CSS/JavaScript: 核心前端技术。
    • Web APIs:
      • FileReader API: 读取用户上传的文件。
      • Web Crypto API: 实现加密解密(我们将使用对称加密 AES-GCM 替代 ECC,因为 Web Crypto 对 ECC 的支持更偏向于密钥协商 ECDH)。
      • getUserMedia API: 调用摄像头。
      • Canvas API: 用于二维码生成和扫描。
      • jsQR Library: 在浏览器中扫描二维码。
      • qrious or kjua Library: 在浏览器中生成二维码。
      • Cache APIIndexedDB: 存储生成的二维码图片和接收的文件(更推荐 IndexedDB,因为它更强大且持久)。
      • Blob URL: 为存储在内存或 IndexedDB 中的文件创建临时下载链接。
    • Web Workers (可选): 对于大文件的加密/解密或二维码生成,可以放到 Web Worker 中执行,避免阻塞主线程。
    • 模块化 (ES6 Modules): 组织代码,使其更清晰。
  2. 发送端 (Sender) 流程:

    • 用户选择文件(多个)。
    • 前端 JavaScript 读取文件,计算 SHA-256。
    • 将文件内容转换为 Base64。
    • 按用户指定的块大小分割 Base64 字符串。
    • 为本次传输生成一个随机的 AES-GCM 密钥。
    • 使用 AES-GCM 密钥加密每个数据块。
    • 按照文档格式拼接每个块的数据字符串(包含文件名、大小、类型、SHA256、索引、总数)。
    • 将密钥(用一个固定的“主密钥”加密,或者直接放在第一个二维码里)和第一个加密块的数据字符串组合,作为第一个二维码的内容。后续二维码只包含加密块数据字符串。
    • 使用 JavaScript 库(如 qrious)将每个数据字符串生成二维码图片(<canvas>data:image URL)。
    • 将生成的二维码图片(或其数据)和原始文件信息存储到 IndexedDB
    • 在网页上以幻灯片形式展示二维码。
  3. 接收端 (Receiver) 流程:

    • 用户点击“开始扫描”按钮,调用摄像头。
    • 使用 jsQR 持续扫描视频流中的二维码。
    • 解析扫描到的二维码数据。
    • 如果是第一个二维码,从中提取 AES-GCM 密钥(并解密,如果之前被加密)。
    • 使用 AES-GCM 密钥解密每个扫描到的块。
    • 解析解密后的数据,提取文件名、内容、索引等信息。
    • 将解密后的块按索引存储在内存或 IndexedDB 中。
    • 在 UI 上显示扫描进度(已扫描/总数)。
    • 计算并显示扫描速度。
    • 当所有块都接收完毕后:
      • 按索引顺序拼接所有块的 Base64 内容。
      • 对拼接后的 Base64 进行解码,得到原始文件的 ArrayBufferBlob
      • 计算重组后文件的 SHA-256 并与元数据中的进行比对(可选,增加可靠性)。
      • 将重组后的文件 Blob 存储到 IndexedDB
      • 在页面上提供一个下载链接(使用 URL.createObjectURL(blob))。
  4. 文件存储与下载:

    • IndexedDB: 使用浏览器的 IndexedDB API 来持久化存储:
      • 生成的二维码图片数据 (Base64 或 Blob)。
      • 原始文件的元数据和 Base64 内容(发送端,用于重新生成)。
      • 接收端解密并重组后的文件 Blob
    • 下载: 当用户需要下载文件时,从 IndexedDB 读取文件 Blob,然后使用 URL.createObjectURL(blob) 创建一个临时的下载链接。

其中,对称加密 AES-GCM是我之前提出的要求,因为我担心ECC加密在这里会有点过于复杂,所以我让AI挑了一个简单些的。
但是不得不说,这个问题也是有的。
它的项目结构如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
qr-file-transfer-frontend/
├── index.html # 主页面 (包含发送和接收的入口)
├── sender.html # 发送端页面
├── receiver.html # 接收端页面
├── css/
│ └── styles.css # 样式文件
├── js/
│ ├── config.js # 配置常量
│ ├── utils.js # 通用工具函数 (SHA256, Base64, 分块等)
│ ├── crypto.js # 加密解密逻辑 (AES-GCM)
│ ├── qrGenerator.js # 二维码生成逻辑
│ ├── qrScanner.js # 二维码扫描逻辑
│ ├── sender.js # 发送端主逻辑
│ ├── receiver.js # 接收端主逻辑
│ ├── storage.js # IndexedDB 存储逻辑
│ └── main.js # 主入口,路由或初始化
├── lib/
│ ├── qrious.min.js # 二维码生成库 (或 kjua)
│ └── jsQR.js # 二维码扫描库
└── README.md # 项目说明

成功…了吧?

在经过我连续的修改后,我这里直接搬出最终版本(2.0的)
二维码文件离线传输工具 (ECC):基于 secp256r1 椭圆曲线加密,纯前端实现
本来我没想让AI走ECC这条路的,但是它中途就跟忘了似的,又沿用了我一开始的要求…
但最终效果还是不错的。

如上所示是我使用566eeadfaea7b2048cc9c46e18a7e379e5f2b2365fadef11336602f7aab45c85这个私钥生成的二维码,这个私钥是我第一次测试使用的,看起来如此亲切…
如果你扫码后,会发现一堆不知道怎么处理的数据,这个就是加密后的数据。
虽然我并没有藏什么彩蛋,如果你专门去处理的话,你会得到这个图片:

总结

不管怎么样,这个项目算是成型了,至于其他的内容,还需要我再去研究。
最后,附上文件(密码:9oag):

点击此处查看