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:
parent
f7ebc084ea
commit
5322f85721
18 changed files with 2387 additions and 61 deletions
134
public/script.js
134
public/script.js
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue