feat: 實作全域檔案傳輸機制與 .tar 封裝規範

## 主要變更

### 新增全域檔案傳輸模組 (src/transfer/)
- constants.ts: 定義傳輸常數(10MB 門檻、5MB chunk 大小)
- types.ts: 傳輸類型定義
- uploadManager.ts: 後端上傳管理器(支援直傳與 chunk)
- downloadManager.ts: 後端下載管理器(支援直傳與 chunk)
- archiveManager.ts: 封裝管理器(僅允許 .tar)
- index.ts: 統一匯出

### 新增 API 端點
- uploadChunk.tsx: Chunk 上傳 API
- downloadChunk.tsx: Chunk 下載 API

### 前端更新
- public/script.js: 整合智慧傳輸策略(≤10MB 直傳,>10MB chunk)
- public/transfer.js: 前端傳輸管理模組

### 轉換器更新
- mineru.ts: 改用 .tar 格式(不壓縮),禁止 .tar.gz
- download.tsx: 使用統一的封裝管理器

### 測試
- tests/transfer/: 完整傳輸機制測試套件
- tests/converters/mineru.test.ts: 更新以符合 .tar 規範

### 文件
- README.md: 新增檔案傳輸機制說明

## 設計原則
- 檔案 ≤10MB:直接傳輸
- 檔案 >10MB:使用 5MB chunks 分段傳輸
- 多檔輸出:僅允許 .tar 封裝(禁止 .tar.gz/.zip)
- 引擎層不感知 chunk(僅傳輸層處理)
This commit is contained in:
Your Name 2026-01-21 13:58:01 +08:00
parent f7ebc084ea
commit 5322f85721
18 changed files with 2387 additions and 61 deletions

View file

@ -242,11 +242,54 @@ const deleteRow = (target) => {
}).catch((err) => console.log(err));
};
// ==================== 全域傳輸常數 ====================
const CHUNK_THRESHOLD_BYTES = 10 * 1024 * 1024; // 10MB
const CHUNK_SIZE_BYTES = 5 * 1024 * 1024; // 5MB
/**
* 判斷是否需要使用 chunk 傳輸
*/
function shouldUseChunkedUpload(fileSize) {
return fileSize > CHUNK_THRESHOLD_BYTES;
}
/**
* 生成 UUID
*/
function generateUploadId() {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID();
}
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === "x" ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
/**
* 統一上傳檔案自動判斷使用直傳或 chunk
*
* @param {File} file - 要上傳的檔案
*/
const uploadFile = (file) => {
convertButton.disabled = true;
convertButton.value = getTranslation("convert", "uploading");
pendingFiles += 1;
if (shouldUseChunkedUpload(file.size)) {
// 大檔:使用 chunk 上傳
uploadFileChunked(file);
} else {
// 小檔:直接上傳
uploadFileDirect(file);
}
};
/**
* 直接上傳10MB
*/
const uploadFileDirect = (file) => {
const formData = new FormData();
formData.append("file", file, file.name);
@ -255,7 +298,12 @@ const uploadFile = (file) => {
xhr.open("POST", `${webroot}/upload`, true);
xhr.onload = () => {
let data = JSON.parse(xhr.responseText);
let data = {};
try {
data = JSON.parse(xhr.responseText);
} catch (e) {
console.log("Parse error:", e);
}
pendingFiles -= 1;
if (pendingFiles === 0) {
@ -265,10 +313,12 @@ const uploadFile = (file) => {
convertButton.value = getTranslation("convert", "convertButton");
}
//Remove the progress bar when upload is done
// Remove the progress bar when upload is done
let progressbar = file.htmlRow.getElementsByTagName("progress");
progressbar[0].parentElement.remove();
console.log(data);
if (progressbar[0]) {
progressbar[0].parentElement.remove();
}
console.log("Direct upload complete:", data);
};
xhr.upload.onprogress = (e) => {
@ -277,16 +327,88 @@ const uploadFile = (file) => {
console.log(`upload progress (${file.name}):`, (100 * sent) / total);
let progressbar = file.htmlRow.getElementsByTagName("progress");
progressbar[0].value = (100 * sent) / total;
if (progressbar[0]) {
progressbar[0].value = (100 * sent) / total;
}
};
xhr.onerror = (e) => {
console.log(e);
console.log("Upload error:", e);
pendingFiles -= 1;
if (pendingFiles === 0) {
convertButton.value = getTranslation("convert", "convertButton");
}
};
xhr.send(formData);
};
/**
* Chunk 上傳>10MB
*/
const uploadFileChunked = async (file) => {
const uploadId = generateUploadId();
const totalChunks = Math.ceil(file.size / CHUNK_SIZE_BYTES);
console.log(`Starting chunked upload: ${file.name}, size: ${file.size}, chunks: ${totalChunks}`);
try {
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
const start = chunkIndex * CHUNK_SIZE_BYTES;
const end = Math.min(start + CHUNK_SIZE_BYTES, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append("upload_id", uploadId);
formData.append("chunk_index", chunkIndex.toString());
formData.append("total_chunks", totalChunks.toString());
formData.append("file_name", file.name);
formData.append("total_size", file.size.toString());
formData.append("chunk", chunk);
const response = await fetch(`${webroot}/upload-chunk`, {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(`Chunk ${chunkIndex} upload failed: ${response.status}`);
}
// 更新進度
const percent = ((chunkIndex + 1) / totalChunks) * 100;
let progressbar = file.htmlRow.getElementsByTagName("progress");
if (progressbar[0]) {
progressbar[0].value = percent;
}
console.log(`Chunk ${chunkIndex + 1}/${totalChunks} uploaded (${percent.toFixed(1)}%)`);
}
// 完成
pendingFiles -= 1;
if (pendingFiles === 0) {
if (formatSelected) {
convertButton.disabled = false;
}
convertButton.value = getTranslation("convert", "convertButton");
}
// Remove the progress bar
let progressbar = file.htmlRow.getElementsByTagName("progress");
if (progressbar[0]) {
progressbar[0].parentElement.remove();
}
console.log("Chunked upload complete:", file.name);
} catch (error) {
console.error("Chunked upload failed:", error);
pendingFiles -= 1;
if (pendingFiles === 0) {
convertButton.value = getTranslation("convert", "convertButton");
}
}
};
const formConvert = document.querySelector(`form[action='${webroot}/convert']`);
formConvert.addEventListener("submit", () => {